From b828bb1d2ae842c2ac2bfdabc28939d8199a7415 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:36:25 +0400
Subject: [PATCH 01/19] fix: cap HTTP response growth in write_callback
Align JSON and getfile download limits with Rust SDK caps and stop exiting
the process on URL-escape allocation failures in query building.
Co-authored-by: Cursor
---
include/ZeroBounce/ZeroBounce.h | 7 +++++++
src/ZeroBounce.c | 28 ++++++++++++++++++++--------
2 files changed, 27 insertions(+), 8 deletions(-)
diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h
index edef966..b12446c 100644
--- a/include/ZeroBounce/ZeroBounce.h
+++ b/include/ZeroBounce/ZeroBounce.h
@@ -91,12 +91,19 @@ bool zb_get_file_json_indicates_error(const char *body);
char *zb_format_get_file_error_message(const char *body);
+/** Cap JSON API responses so error pages cannot exhaust memory (F-01). */
+#define ZB_MAX_JSON_RESPONSE_BYTES (16u * 1024u * 1024u)
+/** Cap bulk getfile downloads (F-01). */
+#define ZB_MAX_GETFILE_BYTES (256u * 1024u * 1024u)
+
/**
* Struct used to store the response data of a request.
+ * max_bytes: 0 uses ZB_MAX_JSON_RESPONSE_BYTES; set ZB_MAX_GETFILE_BYTES for getfile.
*/
typedef struct {
char *response;
size_t size;
+ size_t max_bytes;
} memory;
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index 79c8029..a83ddea 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -44,33 +44,44 @@ static char* zb_url_escape(const char* value) {
char* escaped = curl_easy_escape(NULL, value ? value : "", 0);
char* copy = escaped ? strdup(escaped) : NULL;
curl_free(escaped);
- if (!copy) {
- fprintf(stderr, "Memory allocation failed.\n");
- exit(EXIT_FAILURE);
- }
return copy;
}
-static void zb_append_query_param(StringVector* vector, const char* name, const char* value) {
+static bool zb_append_query_param(StringVector* vector, const char* name, const char* value) {
if (!value || !strlen(value)) {
- return;
+ return true;
}
size_t prefix_len = strlen(name) + 3;
char* prefix = (char*)malloc(prefix_len);
if (!prefix) {
- fprintf(stderr, "Memory allocation failed.\n");
- exit(EXIT_FAILURE);
+ return false;
}
snprintf(prefix, prefix_len, "&%s=", name);
string_vector_append(vector, prefix);
char* escaped = zb_url_escape(value);
+ if (!escaped) {
+ return false;
+ }
string_vector_append(vector, escaped);
free(escaped);
+ return true;
+}
+
+static size_t zb_response_max_bytes(const memory *mem) {
+ if (mem->max_bytes != 0) {
+ return mem->max_bytes;
+ }
+ return ZB_MAX_JSON_RESPONSE_BYTES;
}
static size_t write_callback(void *data, size_t size, size_t nmemb, void *clientp) {
size_t real_size = size * nmemb;
memory *mem = (memory*)clientp;
+ size_t cap = zb_response_max_bytes(mem);
+
+ if (mem->size + real_size > cap) {
+ return 0;
+ }
char *ptr = realloc(mem->response, mem->size + real_size + 1);
if(ptr == NULL)
@@ -710,6 +721,7 @@ static void get_file_internal(
}
memory response_data = {0};
+ response_data.max_bytes = ZB_MAX_GETFILE_BYTES;
long http_code = 0;
char* content_type = NULL;
From d8f91904ed2dd98262813aa1db958675583a4c24 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:59:36 +0400
Subject: [PATCH 02/19] feat(validate): add ZBValidateOptions and activity
response fields
Add validate_*_with_options APIs, batch body test hook, and active_in_days/active_first_seen on responses.
Co-authored-by: Cursor
---
include/ZeroBounce/ZBValidateResponse.h | 2 +
include/ZeroBounce/ZeroBounce.h | 43 ++++++++
src/ZBValidateRespone.c | 6 ++
src/ZeroBounce.c | 133 ++++++++++++++++++++++--
test/ZeroBounceTest.c | 54 ++++++++++
5 files changed, 231 insertions(+), 7 deletions(-)
diff --git a/include/ZeroBounce/ZBValidateResponse.h b/include/ZeroBounce/ZBValidateResponse.h
index e667536..bdb2b14 100644
--- a/include/ZeroBounce/ZBValidateResponse.h
+++ b/include/ZeroBounce/ZBValidateResponse.h
@@ -79,6 +79,8 @@ typedef struct {
char* domain;
char* did_you_mean;
char* domain_age_days;
+ char* active_in_days;
+ char* active_first_seen;
bool free_email;
bool mx_found;
char* mx_record;
diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h
index b12446c..2f3213d 100644
--- a/include/ZeroBounce/ZeroBounce.h
+++ b/include/ZeroBounce/ZeroBounce.h
@@ -80,6 +80,21 @@ SendFileOptions new_send_file_options();
*/
GetFileOptions new_get_file_options(void);
+/**
+ * Optional query/body parameters for GET /validate and POST /validatebatch.
+ * timeout: -1 to omit. activity_data / verify_plus: -1 omit, 0 false, 1 true.
+ */
+typedef struct {
+ int timeout;
+ int activity_data;
+ int verify_plus;
+} ZBValidateOptions;
+
+/**
+ * @brief Default ZBValidateOptions (all parameters omitted).
+ */
+ZBValidateOptions new_validate_options(void);
+
/**
* @brief Whether a getfile response body looks like a JSON error payload (including HTTP 200).
*/
@@ -369,6 +384,18 @@ void validate_email(
OnErrorCallback error_callback
);
+/**
+ * @brief Validates the given email with optional v2 query parameters.
+ */
+void validate_email_with_options(
+ ZeroBounce *zb,
+ char* email,
+ char* ip_address,
+ const ZBValidateOptions *options,
+ OnSuccessCallbackValidate success_callback,
+ OnErrorCallback error_callback
+);
+
/**
* @brief Validates a batch of emails.
*
@@ -384,6 +411,17 @@ void validate_email_batch(
OnErrorCallback error_callback
);
+/**
+ * @brief Validates a batch of emails with optional v2 body parameters.
+ */
+void validate_email_batch_with_options(
+ ZeroBounce *zb,
+ EmailToValidateVector email_batch,
+ const ZBValidateOptions *options,
+ OnSuccessCallbackValidateBatch success_callback,
+ OnErrorCallback error_callback
+);
+
/**
* @brief The send file API allows user to send a file for bulk email validation.
*
@@ -740,4 +778,9 @@ void zero_bounce_set_http_perform_for_test(ZBHttpPerformFn fn);
*/
const char* zero_bounce_get_last_request_url_for_test(void);
+/**
+ * @brief Test hook: returns the JSON body of the most recent validatebatch request.
+ */
+const char* zero_bounce_get_last_post_body_for_test(void);
+
#endif
\ No newline at end of file
diff --git a/src/ZBValidateRespone.c b/src/ZBValidateRespone.c
index 8643d14..15434fe 100644
--- a/src/ZBValidateRespone.c
+++ b/src/ZBValidateRespone.c
@@ -86,6 +86,8 @@ ZBValidateResponse new_zb_validate_response() {
response.domain = "";
response.did_you_mean = "";
response.domain_age_days = "";
+ response.active_in_days = "";
+ response.active_first_seen = "";
response.free_email = false;
response.mx_found = false;
response.mx_record = "";
@@ -204,6 +206,8 @@ ZBValidateResponse zb_validate_response_from_json(const json_object* j) {
r.domain = *(char**)get_json_value(j, json_type_string, "domain", &(char*){""});
r.did_you_mean = *(char**)get_json_value(j, json_type_string, "did_you_mean", &(char*){""});
r.domain_age_days = *(char**)get_json_value(j, json_type_string, "domain_age_days", &(char*){""});
+ r.active_in_days = *(char**)get_json_value(j, json_type_string, "active_in_days", &(char*){""});
+ r.active_first_seen = *(char**)get_json_value(j, json_type_string, "active_first_seen", &(char*){""});
r.free_email = *(bool*)get_json_value(j, json_type_boolean, "free_email", &(bool){false});
if (strcasecmp(*(char**)get_json_value(j, json_type_string, "mx_found", &(char*){"false"}), "true") == 0) {
@@ -242,6 +246,8 @@ int compare_zb_validate_response(const ZBValidateResponse* response1, const ZBVa
strcmp(response1->domain, response2->domain) == 0 &&
strcmp(response1->did_you_mean, response2->did_you_mean) == 0 &&
strcmp(response1->domain_age_days, response2->domain_age_days) == 0 &&
+ strcmp(response1->active_in_days, response2->active_in_days) == 0 &&
+ strcmp(response1->active_first_seen, response2->active_first_seen) == 0 &&
response1->free_email == response2->free_email &&
response1->mx_found == response2->mx_found &&
strcmp(response1->mx_record, response2->mx_record) == 0 &&
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index a83ddea..e32a520 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -22,6 +22,7 @@
static ZBHttpPerformFn s_http_perform = NULL;
static char s_last_request_url_for_test[1024] = {0};
+static char s_last_post_body_for_test[4096] = {0};
void zero_bounce_set_http_perform_for_test(ZBHttpPerformFn fn) {
s_http_perform = fn;
@@ -31,6 +32,10 @@ const char* zero_bounce_get_last_request_url_for_test(void) {
return s_last_request_url_for_test[0] ? s_last_request_url_for_test : NULL;
}
+const char* zero_bounce_get_last_post_body_for_test(void) {
+ return s_last_post_body_for_test[0] ? s_last_post_body_for_test : NULL;
+}
+
static void zero_bounce_record_request_url_for_test(const char* url) {
if (!url) {
s_last_request_url_for_test[0] = '\0';
@@ -142,6 +147,31 @@ GetFileOptions new_get_file_options(void) {
return o;
}
+ZBValidateOptions new_validate_options(void) {
+ ZBValidateOptions o = { -1, -1, -1 };
+ return o;
+}
+
+static bool zb_check_validate_timeout(int timeout, OnErrorCallback error_callback) {
+ if (timeout < 0 || (timeout >= 3 && timeout <= 60)) {
+ return true;
+ }
+ if (error_callback) {
+ error_callback(parse_error("Invalid parameter: timeout (must be between 3 and 60 seconds)"));
+ }
+ return false;
+}
+
+static bool zb_check_validate_batch_timeout(int timeout, OnErrorCallback error_callback) {
+ if (timeout < 0 || (timeout >= 10 && timeout <= 120)) {
+ return true;
+ }
+ if (error_callback) {
+ error_callback(parse_error("Invalid parameter: timeout (must be between 10 and 120 seconds)"));
+ }
+ return false;
+}
+
static bool zb_ct_includes_application_json(const char *content_type) {
if (!content_type) {
@@ -1151,19 +1181,41 @@ void validate_email(
char* ip_address,
OnSuccessCallbackValidate success_callback,
OnErrorCallback error_callback
+) {
+ validate_email_with_options(zb, email, ip_address, NULL, success_callback, error_callback);
+}
+
+void validate_email_with_options(
+ ZeroBounce *zb,
+ char* email,
+ char* ip_address,
+ const ZBValidateOptions *options,
+ OnSuccessCallbackValidate success_callback,
+ OnErrorCallback error_callback
) {
if (zero_bounce_invalid_api_key(zb, error_callback)) return;
- const char *url_pattern = "%s/validate?api_key=%s&email=%s&ip_address=%s";
+ ZBValidateOptions default_options = new_validate_options();
+ if (!options) {
+ options = &default_options;
+ }
+ if (!zb_check_validate_timeout(options->timeout, error_callback)) {
+ return;
+ }
char* api_key_esc = zb_url_escape(zb->api_key);
char* email_esc = zb_url_escape(email ? email : "");
char* ip_esc = zb_url_escape(ip_address ? ip_address : "");
- int url_path_len = snprintf(
- NULL, 0, url_pattern, zb->api_base_url, api_key_esc, email_esc, ip_esc
- );
- char *url_path = malloc(url_path_len + 1);
+ size_t url_cap = strlen(zb->api_base_url) + strlen(api_key_esc) + strlen(email_esc) + strlen(ip_esc) + 256;
+ if (options->timeout >= 0) {
+ url_cap += 32;
+ }
+ if (options->activity_data >= 0 || options->verify_plus >= 0) {
+ url_cap += 64;
+ }
+
+ char *url_path = malloc(url_cap);
if (!url_path) {
free(api_key_esc);
free(email_esc);
@@ -1173,12 +1225,41 @@ void validate_email(
}
snprintf(
- url_path, url_path_len + 1, url_pattern, zb->api_base_url, api_key_esc, email_esc, ip_esc
+ url_path, url_cap, "%s/validate?api_key=%s&email=%s&ip_address=%s",
+ zb->api_base_url, api_key_esc, email_esc, ip_esc
);
+ if (options->timeout >= 0) {
+ char timeout_buf[16];
+ snprintf(timeout_buf, sizeof(timeout_buf), "%d", options->timeout);
+ char* timeout_esc = zb_url_escape(timeout_buf);
+ char* extended = realloc(url_path, strlen(url_path) + strlen(timeout_esc) + 32);
+ if (!extended) {
+ free(timeout_esc);
+ free(api_key_esc);
+ free(email_esc);
+ free(ip_esc);
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ url_path = extended;
+ strcat(url_path, "&timeout=");
+ strcat(url_path, timeout_esc);
+ free(timeout_esc);
+ }
+ if (options->activity_data >= 0) {
+ strcat(url_path, options->activity_data ? "&activity_data=true" : "&activity_data=false");
+ }
+ if (options->verify_plus >= 0) {
+ strcat(url_path, options->verify_plus ? "&verify_plus=true" : "&verify_plus=false");
+ }
+
free(api_key_esc);
free(email_esc);
free(ip_esc);
+ zero_bounce_record_request_url_for_test(url_path);
+
memory response_data = {0};
long http_code;
@@ -1208,15 +1289,32 @@ void validate_email(
free(response_data.response);
}
-
void validate_email_batch(
ZeroBounce *zb,
EmailToValidateVector email_batch,
OnSuccessCallbackValidateBatch success_callback,
OnErrorCallback error_callback
+) {
+ validate_email_batch_with_options(zb, email_batch, NULL, success_callback, error_callback);
+}
+
+void validate_email_batch_with_options(
+ ZeroBounce *zb,
+ EmailToValidateVector email_batch,
+ const ZBValidateOptions *options,
+ OnSuccessCallbackValidateBatch success_callback,
+ OnErrorCallback error_callback
) {
if (zero_bounce_invalid_api_key(zb, error_callback)) return;
+ ZBValidateOptions default_options = new_validate_options();
+ if (!options) {
+ options = &default_options;
+ }
+ if (!zb_check_validate_batch_timeout(options->timeout, error_callback)) {
+ return;
+ }
+
const char *url_pattern = "%s/validatebatch";
int url_path_len = snprintf(
@@ -1247,6 +1345,27 @@ void validate_email_batch(
json_object_object_add(payload, "api_key", json_object_new_string(zb->api_key));
json_object_object_add(payload, "email_batch", email_batch_json);
+ if (options->timeout >= 0) {
+ json_object_object_add(payload, "timeout", json_object_new_int(options->timeout));
+ }
+ if (options->activity_data >= 0) {
+ json_object_object_add(
+ payload,
+ "activity_data",
+ json_object_new_boolean(options->activity_data ? 1 : 0)
+ );
+ }
+ if (options->verify_plus >= 0) {
+ json_object_object_add(
+ payload,
+ "verify_plus",
+ json_object_new_boolean(options->verify_plus ? 1 : 0)
+ );
+ }
+
+ const char* payload_str = json_object_to_json_string(payload);
+ strncpy(s_last_post_body_for_test, payload_str, sizeof(s_last_post_body_for_test) - 1);
+ s_last_post_body_for_test[sizeof(s_last_post_body_for_test) - 1] = '\0';
memory response_data = {0};
long http_code = 0;
diff --git a/test/ZeroBounceTest.c b/test/ZeroBounceTest.c
index 9fd173a..f0d9bf7 100644
--- a/test/ZeroBounceTest.c
+++ b/test/ZeroBounceTest.c
@@ -367,6 +367,58 @@ void test_validate_email_batch_invalid(void)
validate_email_batch(zb, emails, on_success_validate_batch_invalid, on_error_invalid);
}
+void test_validate_email_with_options_query_params(void)
+{
+ response_json = "{\"address\":\"valid@example.com\",\"status\":\"valid\",\"sub_status\":\"\",\"processed_at\":\"2023-04-05 13:29:47.553\"}";
+
+ get_http_code_fake.return_val = 200;
+
+ expected_validate_response = zb_validate_response_from_json(json_tokener_parse(response_json));
+
+ ZBValidateOptions options = new_validate_options();
+ options.timeout = 30;
+ options.activity_data = 1;
+ options.verify_plus = 0;
+
+ validate_email_with_options(
+ zb, "valid@example.com", "", &options, on_success_validate_valid, on_error_valid
+ );
+
+ const char* url = zero_bounce_get_last_request_url_for_test();
+ TEST_ASSERT_NOT_NULL(url);
+ TEST_ASSERT_NOT_NULL(strstr(url, "timeout=30"));
+ TEST_ASSERT_NOT_NULL(strstr(url, "activity_data=true"));
+ TEST_ASSERT_NOT_NULL(strstr(url, "verify_plus=false"));
+}
+
+void test_validate_email_batch_with_options_body(void)
+{
+ response_json = "{\"email_batch\":[],\"errors\":[]}";
+
+ get_http_code_fake.return_val = 200;
+
+ expected_validate_batch_response = zb_validate_batch_response_from_json(json_tokener_parse(response_json));
+
+ EmailToValidateVector emails = email_to_validate_vector_init();
+ ZBEmailToValidate email = {"valid@example.com", "1.1.1.1"};
+ email_to_validate_vector_append(&emails, email);
+
+ ZBValidateOptions options = new_validate_options();
+ options.timeout = 60;
+ options.verify_plus = 1;
+
+ validate_email_batch_with_options(
+ zb, emails, &options, on_success_validate_batch_valid, on_error_valid
+ );
+
+ const char* body = zero_bounce_get_last_post_body_for_test();
+ TEST_ASSERT_NOT_NULL(body);
+ TEST_ASSERT_NOT_NULL(strstr(body, "\"timeout\":60"));
+ TEST_ASSERT_NOT_NULL(strstr(body, "\"verify_plus\":true"));
+
+ email_to_validate_vector_free(&emails);
+}
+
void test_validate_email_batch_uses_api_url(void)
{
response_json = "{\"email_batch\":[],\"errors\":[]}";
@@ -921,8 +973,10 @@ int main(void)
RUN_TEST(test_get_api_usage_response_parse_and_compare);
RUN_TEST(test_validate_email_invalid);
RUN_TEST(test_validate_email_valid);
+ RUN_TEST(test_validate_email_with_options_query_params);
RUN_TEST(test_validate_email_batch_invalid);
RUN_TEST(test_validate_email_batch_uses_api_url);
+ RUN_TEST(test_validate_email_batch_with_options_body);
RUN_TEST(test_validate_email_batch_valid);
RUN_TEST(test_send_file_invalid);
RUN_TEST(test_send_file_valid);
From c935d4ecae84244255503bfdf96e0f2aae0d5db5 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 03/19] fix: validate URL growth and batch response copy
Grow validate query URLs with realloc before appending optional params, and copy activity fields in batch response vectors to avoid heap corruption in tests.
Co-authored-by: Cursor
---
src/ZBValidateBatchResponse.c | 4 ++++
src/ZeroBounce.c | 29 +++++++++++++++++++++++++++--
2 files changed, 31 insertions(+), 2 deletions(-)
diff --git a/src/ZBValidateBatchResponse.c b/src/ZBValidateBatchResponse.c
index 97fa6ea..771e884 100644
--- a/src/ZBValidateBatchResponse.c
+++ b/src/ZBValidateBatchResponse.c
@@ -117,6 +117,8 @@ void validate_response_vector_append(ValidateResponseVector* vector, const ZBVal
vector->data[vector->size].domain = strdup(response.domain);
vector->data[vector->size].did_you_mean = strdup(response.did_you_mean);
vector->data[vector->size].domain_age_days = strdup(response.domain_age_days);
+ vector->data[vector->size].active_in_days = strdup(response.active_in_days);
+ vector->data[vector->size].active_first_seen = strdup(response.active_first_seen);
vector->data[vector->size].free_email = response.free_email;
vector->data[vector->size].mx_found = response.mx_found;
vector->data[vector->size].catchall_domain = response.catchall_domain;
@@ -144,6 +146,8 @@ void validate_response_vector_free(ValidateResponseVector* vector) {
free(vector->data[i].domain);
free(vector->data[i].did_you_mean);
free(vector->data[i].domain_age_days);
+ free(vector->data[i].active_in_days);
+ free(vector->data[i].active_first_seen);
free(vector->data[i].mx_record);
free(vector->data[i].smtp_provider);
free(vector->data[i].first_name);
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index e32a520..e767dfb 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -72,6 +72,21 @@ static bool zb_append_query_param(StringVector* vector, const char* name, const
return true;
}
+static bool zb_url_append_suffix(char **url_path, const char *suffix) {
+ if (!url_path || !*url_path || !suffix) {
+ return false;
+ }
+ size_t old_len = strlen(*url_path);
+ size_t suffix_len = strlen(suffix);
+ char *extended = realloc(*url_path, old_len + suffix_len + 1);
+ if (!extended) {
+ return false;
+ }
+ memcpy(extended + old_len, suffix, suffix_len + 1);
+ *url_path = extended;
+ return true;
+}
+
static size_t zb_response_max_bytes(const memory *mem) {
if (mem->max_bytes != 0) {
return mem->max_bytes;
@@ -1248,10 +1263,20 @@ void validate_email_with_options(
free(timeout_esc);
}
if (options->activity_data >= 0) {
- strcat(url_path, options->activity_data ? "&activity_data=true" : "&activity_data=false");
+ const char *suffix = options->activity_data ? "&activity_data=true" : "&activity_data=false";
+ if (!zb_url_append_suffix(&url_path, suffix)) {
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
}
if (options->verify_plus >= 0) {
- strcat(url_path, options->verify_plus ? "&verify_plus=true" : "&verify_plus=false");
+ const char *suffix = options->verify_plus ? "&verify_plus=true" : "&verify_plus=false";
+ if (!zb_url_append_suffix(&url_path, suffix)) {
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
}
free(api_key_esc);
From 2de6d056ed769972cb1674a4e103204413735278 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:25:40 +0400
Subject: [PATCH 04/19] test: relax validatebatch body JSON assertions
json-c may insert spaces after colons in serialized batch payloads.
Co-authored-by: Cursor
---
test/ZeroBounceTest.c | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/test/ZeroBounceTest.c b/test/ZeroBounceTest.c
index f0d9bf7..0a70c98 100644
--- a/test/ZeroBounceTest.c
+++ b/test/ZeroBounceTest.c
@@ -413,8 +413,10 @@ void test_validate_email_batch_with_options_body(void)
const char* body = zero_bounce_get_last_post_body_for_test();
TEST_ASSERT_NOT_NULL(body);
- TEST_ASSERT_NOT_NULL(strstr(body, "\"timeout\":60"));
- TEST_ASSERT_NOT_NULL(strstr(body, "\"verify_plus\":true"));
+ TEST_ASSERT_NOT_NULL(strstr(body, "\"timeout\""));
+ TEST_ASSERT_NOT_NULL(strstr(body, "60"));
+ TEST_ASSERT_NOT_NULL(strstr(body, "\"verify_plus\""));
+ TEST_ASSERT_NOT_NULL(strstr(body, "true"));
email_to_validate_vector_free(&emails);
}
From 54af6fc780225afa9cf7004df21e06faa3d229b5 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:59:39 +0400
Subject: [PATCH 05/19] feat(scoring): add score_email GET/POST for /v2/scoring
Add ZBScoreEmailResponse plus score_email and score_email_post with form-encoded POST, matching JS scoreEmail parity.
Co-authored-by: Cursor
---
include/ZeroBounce/ZBScoreEmailResponse.h | 28 ++++
include/ZeroBounce/ZeroBounce.h | 22 +++
src/ZBScoreEmailResponse.c | 83 +++++++++++
src/ZeroBounce.c | 165 ++++++++++++++++++++++
test/ZeroBounceTest.c | 47 ++++++
5 files changed, 345 insertions(+)
create mode 100644 include/ZeroBounce/ZBScoreEmailResponse.h
create mode 100644 src/ZBScoreEmailResponse.c
diff --git a/include/ZeroBounce/ZBScoreEmailResponse.h b/include/ZeroBounce/ZBScoreEmailResponse.h
new file mode 100644
index 0000000..186554d
--- /dev/null
+++ b/include/ZeroBounce/ZBScoreEmailResponse.h
@@ -0,0 +1,28 @@
+#ifndef ZBSCOREEMAILRESPONSE_H
+#define ZBSCOREEMAILRESPONSE_H
+
+#include
+
+#include
+
+/**
+ * @brief Response for GET/POST /scoring (real-time AI scoring).
+ */
+typedef struct {
+ char* email;
+ int score;
+ bool score_is_set;
+} ZBScoreEmailResponse;
+
+ZBScoreEmailResponse new_zb_score_email_response();
+
+char* zb_score_email_response_to_string(ZBScoreEmailResponse* response);
+
+ZBScoreEmailResponse zb_score_email_response_from_json(const json_object* j);
+
+int compare_zb_score_email_response(
+ const ZBScoreEmailResponse* response1,
+ const ZBScoreEmailResponse* response2
+);
+
+#endif
diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h
index 2f3213d..2c70b81 100644
--- a/include/ZeroBounce/ZeroBounce.h
+++ b/include/ZeroBounce/ZeroBounce.h
@@ -19,6 +19,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"
@@ -33,6 +34,7 @@ typedef void (*OnSuccessCallbackFileStatus)(ZBFileStatusResponse response);
typedef void (*OnSuccessCallbackGetFile)(ZBGetFileResponse response);
typedef void (*OnSuccessCallbackDeleteFile)(ZBDeleteFileResponse response);
typedef void (*OnSuccessCallbackActivityData)(ZBActivityDataResponse response);
+typedef void (*OnSuccessCallbackScoreEmail)(ZBScoreEmailResponse response);
typedef void (*OnSuccessCallbackFindEmail)(ZBFindEmailResponse response);
typedef void (*OnSuccessCallbackDomainSearch)(ZBDomainSearchResponse response);
@@ -595,6 +597,26 @@ void get_activity_data(
OnErrorCallback error_callback
);
+/**
+ * @brief Real-time AI scoring for a single email (GET /scoring).
+ */
+void score_email(
+ ZeroBounce *zb,
+ char* email,
+ OnSuccessCallbackScoreEmail success_callback,
+ OnErrorCallback error_callback
+);
+
+/**
+ * @brief Real-time AI scoring for a single email (POST /scoring, form-encoded).
+ */
+void score_email_post(
+ ZeroBounce *zb,
+ char* email,
+ OnSuccessCallbackScoreEmail success_callback,
+ OnErrorCallback error_callback
+);
+
/**
* @brief Email Address Search - Identifies and validates a person’s primary email address
*
diff --git a/src/ZBScoreEmailResponse.c b/src/ZBScoreEmailResponse.c
new file mode 100644
index 0000000..a60b67a
--- /dev/null
+++ b/src/ZBScoreEmailResponse.c
@@ -0,0 +1,83 @@
+#include
+#include
+
+#include "ZeroBounce/ZBScoreEmailResponse.h"
+#include "ZeroBounce/utils.h"
+
+ZBScoreEmailResponse new_zb_score_email_response() {
+ ZBScoreEmailResponse response;
+ response.email = "";
+ response.score = 0;
+ response.score_is_set = false;
+ return response;
+}
+
+char* zb_score_email_response_to_string(ZBScoreEmailResponse* response) {
+ const char *serialization = "ZBScoreEmailResponse{"
+ "email='%s', "
+ "score=%d, "
+ "score_is_set=%d"
+ "}";
+
+ int size = snprintf(
+ NULL, 0, serialization,
+ response->email,
+ response->score,
+ response->score_is_set ? 1 : 0
+ );
+
+ if (size < 0) {
+ return NULL;
+ }
+
+ char* buffer = (char*) malloc(size + 1);
+ if (!buffer) {
+ return NULL;
+ }
+
+ snprintf(
+ buffer, size + 1, serialization,
+ response->email,
+ response->score,
+ response->score_is_set ? 1 : 0
+ );
+ return buffer;
+}
+
+static bool parse_score_field(const json_object* j, int* score_out) {
+ json_object* score_obj = NULL;
+ if (!json_object_object_get_ex(j, "score", &score_obj) || score_obj == NULL) {
+ return false;
+ }
+ if (json_object_is_type(score_obj, json_type_int)) {
+ *score_out = json_object_get_int(score_obj);
+ return true;
+ }
+ if (json_object_is_type(score_obj, json_type_string)) {
+ *score_out = atoi(json_object_get_string(score_obj));
+ return true;
+ }
+ return false;
+}
+
+ZBScoreEmailResponse zb_score_email_response_from_json(const json_object* j) {
+ ZBScoreEmailResponse r = new_zb_score_email_response();
+
+ r.email = *(char**)get_json_value(j, json_type_string, "email", &(char*){""});
+ int score = 0;
+ if (parse_score_field(j, &score)) {
+ r.score = score;
+ r.score_is_set = true;
+ }
+
+ return r;
+}
+
+int compare_zb_score_email_response(
+ const ZBScoreEmailResponse* response1,
+ const ZBScoreEmailResponse* response2
+) {
+ return strcmp(response1->email, response2->email) == 0 &&
+ response1->score == response2->score &&
+ response1->score_is_set == response2->score_is_set;
+}
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index e767dfb..892e8c9 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -448,6 +448,63 @@ static bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_ca
}
+static int make_request_with_post_body(
+ char* url_path,
+ char* body,
+ memory* response_data,
+ long* http_code,
+ OnErrorCallback error_callback
+) {
+ strncpy(s_last_post_body_for_test, body ? body : "", sizeof(s_last_post_body_for_test) - 1);
+ s_last_post_body_for_test[sizeof(s_last_post_body_for_test) - 1] = '\0';
+ zero_bounce_record_request_url_for_test(url_path);
+
+ if (s_http_perform) {
+ return s_http_perform(response_data, http_code, NULL);
+ }
+
+ CURL* curl = curl_easy_init();
+ if (!curl) {
+ error_callback(parse_error("Failed to initialize libcurl"));
+ curl_easy_cleanup(curl);
+ return 0;
+ }
+
+ curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "POST");
+ curl_easy_setopt(curl, CURLOPT_URL, url_path);
+
+ struct curl_slist* headers = NULL;
+ headers = curl_slist_append(headers, "Accept: application/json");
+ headers = curl_slist_append(headers, "Content-Type: application/x-www-form-urlencoded");
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
+ curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, body);
+
+ set_write_callback(curl, response_data);
+
+ CURLcode res = curl_easy_perform(curl);
+ if (res != CURLE_OK) {
+ char error_message[300];
+ sprintf(
+ error_message,
+ "Failed to perform request. CURLcode: %d. Error: %s",
+ res,
+ curl_easy_strerror(res)
+ );
+ error_callback(parse_error(error_message));
+
+ curl_easy_cleanup(curl);
+ curl_slist_free_all(headers);
+ return 0;
+ }
+
+ *http_code = get_http_code(curl);
+
+ curl_easy_cleanup(curl);
+ curl_slist_free_all(headers);
+
+ return 1;
+}
+
static int make_request(
char* url_path,
char* request_type,
@@ -1635,6 +1692,114 @@ void get_activity_data(
free(response_data.response);
}
+static void score_email_internal(
+ ZeroBounce *zb,
+ bool use_post,
+ char* email,
+ OnSuccessCallbackScoreEmail success_callback,
+ OnErrorCallback error_callback
+) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) return;
+
+ char* api_key_esc = zb_url_escape(zb->api_key);
+ char* email_esc = zb_url_escape(email ? email : "");
+
+ char *url_path = NULL;
+ char *post_body = NULL;
+
+ if (use_post) {
+ const char *url_pattern = "%s/scoring";
+ int url_path_len = snprintf(NULL, 0, url_pattern, zb->api_base_url);
+ url_path = malloc(url_path_len + 1);
+ if (!url_path) {
+ goto alloc_fail;
+ }
+ snprintf(url_path, url_path_len + 1, url_pattern, zb->api_base_url);
+
+ const char *body_pattern = "api_key=%s&email=%s";
+ int body_len = snprintf(NULL, 0, body_pattern, api_key_esc, email_esc);
+ post_body = malloc(body_len + 1);
+ if (!post_body) {
+ goto alloc_fail;
+ }
+ snprintf(post_body, body_len + 1, body_pattern, api_key_esc, email_esc);
+ } else {
+ const char *url_pattern = "%s/scoring?api_key=%s&email=%s";
+ int url_path_len = snprintf(
+ NULL, 0, url_pattern, zb->api_base_url, api_key_esc, email_esc
+ );
+ url_path = malloc(url_path_len + 1);
+ if (!url_path) {
+ goto alloc_fail;
+ }
+ snprintf(
+ url_path, url_path_len + 1, url_pattern, zb->api_base_url, api_key_esc, email_esc
+ );
+ zero_bounce_record_request_url_for_test(url_path);
+ }
+
+ free(api_key_esc);
+ api_key_esc = NULL;
+ free(email_esc);
+ email_esc = NULL;
+
+ memory response_data = {0};
+ long http_code = 0;
+ int ok = use_post
+ ? make_request_with_post_body(url_path, post_body, &response_data, &http_code, error_callback)
+ : make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback);
+
+ if (!ok) {
+ goto cleanup;
+ }
+
+ if (http_code > 299) {
+ if (error_callback) {
+ error_callback(parse_error(response_data.response));
+ }
+ } else if (success_callback) {
+ json_object *j_obj = json_tokener_parse(response_data.response);
+ if (j_obj == NULL) {
+ error_callback(parse_error("Failed to parse json string"));
+ goto cleanup;
+ }
+
+ success_callback(zb_score_email_response_from_json(j_obj));
+ json_object_put(j_obj);
+ }
+
+ goto cleanup;
+
+alloc_fail:
+ free(api_key_esc);
+ free(email_esc);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+
+cleanup:
+ free(url_path);
+ free(post_body);
+ free(response_data.response);
+}
+
+void score_email(
+ ZeroBounce *zb,
+ char* email,
+ OnSuccessCallbackScoreEmail success_callback,
+ OnErrorCallback error_callback
+) {
+ score_email_internal(zb, false, email, success_callback, error_callback);
+}
+
+void score_email_post(
+ ZeroBounce *zb,
+ char* email,
+ OnSuccessCallbackScoreEmail success_callback,
+ OnErrorCallback error_callback
+) {
+ score_email_internal(zb, true, email, success_callback, error_callback);
+}
+
void find_email_by_domain_first_middle_last_name(
ZeroBounce* zb,
diff --git a/test/ZeroBounceTest.c b/test/ZeroBounceTest.c
index 0a70c98..aa7d1e6 100644
--- a/test/ZeroBounceTest.c
+++ b/test/ZeroBounceTest.c
@@ -18,6 +18,7 @@ static ZBFileStatusResponse expected_file_status_response;
static ZBGetFileResponse expected_get_file_response;
static ZBDeleteFileResponse expected_delete_file_response;
static ZBActivityDataResponse expected_activity_data_response;
+static ZBScoreEmailResponse expected_score_email_response;
static ZBFindEmailResponse expected_find_email_response;
static ZBDomainSearchResponse expected_domain_search_response;
@@ -104,6 +105,14 @@ void on_success_activity_data_invalid(ZBActivityDataResponse response) {
TEST_FAIL_MESSAGE(zb_activity_data_response_to_string(&response));
}
+void on_success_score_email_valid(ZBScoreEmailResponse response) {
+ TEST_ASSERT_EQUAL_INT(1, compare_zb_score_email_response(&expected_score_email_response, &response));
+}
+
+void on_success_score_email_invalid(ZBScoreEmailResponse response) {
+ TEST_FAIL_MESSAGE(zb_score_email_response_to_string(&response));
+}
+
void on_success_find_email_valid(ZBFindEmailResponse response) {
TEST_ASSERT_TRUE(
zb_find_email_response_compare(&expected_find_email_response, &response)
@@ -919,6 +928,42 @@ void test_search_domain_status_valid_payload(void) {
}
+void test_score_email_valid(void) {
+ response_json = "{\"email\":\"mark@gmail.com\",\"score\":10}";
+
+ expected_score_email_response = zb_score_email_response_from_json(
+ json_tokener_parse(response_json)
+ );
+
+ get_http_code_fake.return_val = 200;
+ score_email(zb, "mark@gmail.com", on_success_score_email_valid, on_error_valid);
+
+ const char* url = zero_bounce_get_last_request_url_for_test();
+ TEST_ASSERT_NOT_NULL(url);
+ TEST_ASSERT_NOT_NULL(strstr(url, "/scoring?"));
+ TEST_ASSERT_NOT_NULL(strstr(url, "email="));
+}
+
+void test_score_email_post_valid(void) {
+ response_json = "{\"email\":\"mark@gmail.com\",\"score\":10}";
+
+ expected_score_email_response = zb_score_email_response_from_json(
+ json_tokener_parse(response_json)
+ );
+
+ get_http_code_fake.return_val = 200;
+ score_email_post(zb, "mark@gmail.com", on_success_score_email_valid, on_error_valid);
+
+ const char* url = zero_bounce_get_last_request_url_for_test();
+ TEST_ASSERT_NOT_NULL(url);
+ TEST_ASSERT_NOT_NULL(strstr(url, "/scoring"));
+
+ const char* body = zero_bounce_get_last_post_body_for_test();
+ TEST_ASSERT_NOT_NULL(body);
+ TEST_ASSERT_NOT_NULL(strstr(body, "api_key="));
+ TEST_ASSERT_NOT_NULL(strstr(body, "email="));
+}
+
void test_search_domain_status_serialize_functions() {
response_json = "{\n"
" \"domain\": \"example.com\",\n"
@@ -999,6 +1044,8 @@ int main(void)
RUN_TEST(test_scoring_get_file_valid);
RUN_TEST(test_scoring_delete_file_invalid);
RUN_TEST(test_scoring_delete_file_valid);
+ RUN_TEST(test_score_email_valid);
+ RUN_TEST(test_score_email_post_valid);
RUN_TEST(test_find_email_status_invalid_payload);
RUN_TEST(test_find_email_status_valid_payload);
RUN_TEST(test_search_domain_status_invalid_payload);
From a68ed65a5ac30bb15c2296d736ca81ba46bad375 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 03:00:29 +0400
Subject: [PATCH 06/19] feat(priority3): bulk finder, evaluator, filters, and
S3
Add Priority3 C API surface aligned with C++/JS bulkapi endpoints and Unity mock tests for validateS3file.
Co-authored-by: Cursor
---
include/ZeroBounce/ZBPriority3Options.h | 50 ++
include/ZeroBounce/ZeroBounce.h | 138 +++-
include/ZeroBounce/zb_internal.h | 26 +
src/Priority3.c | 887 ++++++++++++++++++++++++
src/ZeroBounce.c | 42 +-
test/ZeroBounceTest.c | 22 +
6 files changed, 1148 insertions(+), 17 deletions(-)
create mode 100644 include/ZeroBounce/ZBPriority3Options.h
create mode 100644 include/ZeroBounce/zb_internal.h
create mode 100644 src/Priority3.c
diff --git a/include/ZeroBounce/ZBPriority3Options.h b/include/ZeroBounce/ZBPriority3Options.h
new file mode 100644
index 0000000..8c7b7cf
--- /dev/null
+++ b/include/ZeroBounce/ZBPriority3Options.h
@@ -0,0 +1,50 @@
+#ifndef ZB_PRIORITY3_OPTIONS_H
+#define ZB_PRIORITY3_OPTIONS_H
+
+#include
+
+/**
+ * Optional columns for bulk email finder sendfile.
+ * Column indexes start at 1. has_header_row: -1 omit, 1 sends "true".
+ */
+typedef struct {
+ int first_name_column;
+ int last_name_column;
+ int middle_name_column;
+ int full_name_column;
+ int has_header_row;
+} EmailFinderSendFileOptions;
+
+typedef struct {
+ int has_header_row;
+} DomainSearchSendFileOptions;
+
+typedef struct {
+ const char *file_name;
+ int has_header_row;
+ int remove_duplicates;
+ int remove_duplicate;
+ int email_address_column;
+ const char *return_url;
+ int first_name_column;
+ int last_name_column;
+ int gender_column;
+ int ip_address_column;
+ bool allow_phase_2_is_set;
+ bool allow_phase_2;
+} ValidateS3FileOptions;
+
+typedef struct {
+ const char *file_name;
+ int has_header_row;
+ int remove_duplicates;
+ int remove_duplicate;
+ int email_address_column;
+} ScoreS3FileOptions;
+
+EmailFinderSendFileOptions new_email_finder_send_file_options(void);
+DomainSearchSendFileOptions new_domain_search_send_file_options(void);
+ValidateS3FileOptions new_validate_s3_file_options(void);
+ScoreS3FileOptions new_score_s3_file_options(void);
+
+#endif
diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h
index 2c70b81..0009ed4 100644
--- a/include/ZeroBounce/ZeroBounce.h
+++ b/include/ZeroBounce/ZeroBounce.h
@@ -23,6 +23,7 @@
#include "ZeroBounce/ZBFindEmailResponse.h"
#include "ZeroBounce/ZBDomainSearchResponse.h"
#include "ZeroBounce/ZBApiURL.h"
+#include "ZeroBounce/ZBPriority3Options.h"
typedef void (*OnErrorCallback)(ZBErrorResponse error_response);
typedef void (*OnSuccessCallbackCredits)(ZBCreditsResponse response);
@@ -37,6 +38,8 @@ typedef void (*OnSuccessCallbackActivityData)(ZBActivityDataResponse response);
typedef void (*OnSuccessCallbackScoreEmail)(ZBScoreEmailResponse response);
typedef void (*OnSuccessCallbackFindEmail)(ZBFindEmailResponse response);
typedef void (*OnSuccessCallbackDomainSearch)(ZBDomainSearchResponse response);
+/** Raw JSON body; valid only for the duration of the callback. */
+typedef void (*OnSuccessCallbackRawJson)(const char* json_response);
/**
@@ -155,6 +158,7 @@ char* get_content_type_value(CURL* curl);
typedef struct {
char* api_key;
char* api_base_url;
+ const char* bulk_api_root_url;
const char* bulk_api_base_url;
const char* bulk_api_scoring_base_url;
} ZeroBounce;
@@ -199,7 +203,6 @@ void zero_bounce_initialize_with_base_url(ZeroBounce* zb, const char* api_key, Z
* @param error_callback error callback
* @return **true** if the [api_key] is null or **false** otherwise
*/
-static bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback);
/**
* @brief Internal function used to make libcurl requests.
@@ -764,6 +767,139 @@ void search_domain_by_company_name(
OnErrorCallback error_callback
);
+/* Priority 3 — bulk email finder, domain search, list evaluator, filters, S3 */
+
+void send_email_finder_file(
+ ZeroBounce *zb,
+ char *file_path,
+ int domain_column,
+ const EmailFinderSendFileOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void email_finder_file_status(
+ ZeroBounce *zb,
+ char *file_id,
+ OnSuccessCallbackFileStatus success_callback,
+ OnErrorCallback error_callback
+);
+
+void email_finder_get_file(
+ ZeroBounce *zb,
+ char *file_id,
+ char *local_download_path,
+ OnSuccessCallbackGetFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void email_finder_delete_file(
+ ZeroBounce *zb,
+ char *file_id,
+ OnSuccessCallbackDeleteFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void send_domain_search_file(
+ ZeroBounce *zb,
+ char *file_path,
+ int domain_column,
+ const DomainSearchSendFileOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void domain_search_file_status(
+ ZeroBounce *zb,
+ char *file_id,
+ OnSuccessCallbackFileStatus success_callback,
+ OnErrorCallback error_callback
+);
+
+void domain_search_get_file(
+ ZeroBounce *zb,
+ char *file_id,
+ char *local_download_path,
+ OnSuccessCallbackGetFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void domain_search_delete_file(
+ ZeroBounce *zb,
+ char *file_id,
+ OnSuccessCallbackDeleteFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void send_list_evaluator_file(
+ ZeroBounce *zb,
+ char *file_path,
+ int email_address_column,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void get_list_evaluator_result(
+ ZeroBounce *zb,
+ char *file_id,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void send_evaluated_file(
+ ZeroBounce *zb,
+ char *file_path,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void add_filter(
+ ZeroBounce *zb,
+ char *rule,
+ char *target,
+ char *value,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void list_filters(
+ ZeroBounce *zb,
+ bool use_post,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void delete_filter(
+ ZeroBounce *zb,
+ char *rule,
+ char *target,
+ char *value,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void validate_s3_file(
+ ZeroBounce *zb,
+ const ValidateS3FileOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void score_s3_file(
+ ZeroBounce *zb,
+ const ScoreS3FileOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void export_s3_file(
+ ZeroBounce *zb,
+ char *file_id,
+ char *export_bucket,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
/**
* @brief Email Address Search - Identifies and validates a person’s primary email address
*
diff --git a/include/ZeroBounce/zb_internal.h b/include/ZeroBounce/zb_internal.h
new file mode 100644
index 0000000..349bc75
--- /dev/null
+++ b/include/ZeroBounce/zb_internal.h
@@ -0,0 +1,26 @@
+#ifndef ZB_INTERNAL_H
+#define ZB_INTERNAL_H
+
+#include "ZeroBounce/ZeroBounce.h"
+
+bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback);
+char *zb_url_escape(const char *value);
+int zb_make_request(
+ char *url_path,
+ char *request_type,
+ char *header,
+ memory *response_data,
+ long *http_code,
+ OnErrorCallback error_callback
+);
+int zb_make_request_with_post_body(
+ char *url_path,
+ char *body,
+ memory *response_data,
+ long *http_code,
+ OnErrorCallback error_callback
+);
+void zero_bounce_record_request_url_for_test(const char *url);
+int zb_http_perform_if_mock(memory *response_data, long *http_code, char **content_type_out);
+
+#endif
diff --git a/src/Priority3.c b/src/Priority3.c
new file mode 100644
index 0000000..7b1e28f
--- /dev/null
+++ b/src/Priority3.c
@@ -0,0 +1,887 @@
+#include
+#include
+#include
+#include
+
+#include
+
+#include "ZeroBounce/ZeroBounce.h"
+#include "ZeroBounce/zb_internal.h"
+
+typedef void (*p3_mime_fields_fn)(curl_mime *multipart, void *ctx);
+
+EmailFinderSendFileOptions new_email_finder_send_file_options(void) {
+ EmailFinderSendFileOptions o = {0, 0, 0, 0, -1};
+ return o;
+}
+
+DomainSearchSendFileOptions new_domain_search_send_file_options(void) {
+ DomainSearchSendFileOptions o = {-1};
+ return o;
+}
+
+ValidateS3FileOptions new_validate_s3_file_options(void) {
+ ValidateS3FileOptions o = {0};
+ o.has_header_row = -1;
+ o.remove_duplicates = -1;
+ o.remove_duplicate = -1;
+ o.email_address_column = -1;
+ return o;
+}
+
+ScoreS3FileOptions new_score_s3_file_options(void) {
+ ScoreS3FileOptions o = {0};
+ o.has_header_row = -1;
+ o.remove_duplicates = -1;
+ o.remove_duplicate = -1;
+ o.email_address_column = -1;
+ return o;
+}
+
+static bool p3_append_form_field(char **body, size_t *cap, const char *key, const char *value) {
+ if (!key || !value) {
+ return false;
+ }
+ char *ek = zb_url_escape(key);
+ char *ev = zb_url_escape(value);
+ if (!ek || !ev) {
+ free(ek);
+ free(ev);
+ return false;
+ }
+ size_t need = strlen(ek) + strlen(ev) + 2 + (*body ? strlen(*body) + 1 : 0);
+ if (*body && strchr(*body, '=')) {
+ need += 1;
+ }
+ if (need > *cap) {
+ size_t new_cap = (*cap == 0) ? 256 : *cap * 2;
+ while (new_cap < need) {
+ new_cap *= 2;
+ }
+ char *n = realloc(*body, new_cap);
+ if (!n) {
+ free(ek);
+ free(ev);
+ return false;
+ }
+ *body = n;
+ *cap = new_cap;
+ }
+ if (!*body) {
+ *body = calloc(1, *cap);
+ if (!*body) {
+ free(ek);
+ free(ev);
+ return false;
+ }
+ }
+ size_t len = strlen(*body);
+ if (len > 0) {
+ strcat(*body, "&");
+ }
+ strcat(*body, ek);
+ strcat(*body, "=");
+ strcat(*body, ev);
+ free(ek);
+ free(ev);
+ return true;
+}
+
+static void p3_append_optional_bool_form(char **body, size_t *cap, const char *key, int tri_state) {
+ if (tri_state < 0) {
+ return;
+ }
+ p3_append_form_field(body, cap, key, tri_state ? "true" : "false");
+}
+
+static void p3_send_json_success(
+ memory *response_data,
+ long http_code,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ if (http_code > 299) {
+ if (error_callback) {
+ error_callback(parse_error(response_data->response));
+ }
+ return;
+ }
+ if (success_callback) {
+ success_callback(response_data->response ? response_data->response : "");
+ }
+}
+
+static void p3_raw_json_get(
+ ZeroBounce *zb,
+ char *url_path,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ memory response_data = {0};
+ long http_code = 0;
+ if (!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)) {
+ free(response_data.response);
+ free(url_path);
+ return;
+ }
+ p3_send_json_success(&response_data, http_code, success_callback, error_callback);
+ free(response_data.response);
+ free(url_path);
+}
+
+static void p3_raw_json_form_post(
+ ZeroBounce *zb,
+ char *url_path,
+ char *body,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ memory response_data = {0};
+ long http_code = 0;
+ if (!zb_make_request_with_post_body(url_path, body, &response_data, &http_code, error_callback)) {
+ free(response_data.response);
+ free(url_path);
+ free(body);
+ return;
+ }
+ p3_send_json_success(&response_data, http_code, success_callback, error_callback);
+ free(response_data.response);
+ free(url_path);
+ free(body);
+}
+
+static void p3_send_file_json_success(
+ memory *response_data,
+ long http_code,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ if (http_code > 299) {
+ if (error_callback) {
+ error_callback(parse_error(response_data->response));
+ }
+ return;
+ }
+ if (success_callback) {
+ json_object *j_obj = json_tokener_parse(response_data->response);
+ if (j_obj == NULL) {
+ if (error_callback) {
+ error_callback(parse_error("Failed to parse json string"));
+ }
+ return;
+ }
+ success_callback(zb_send_file_response_from_json(j_obj));
+ json_object_put(j_obj);
+ }
+}
+
+static void p3_post_send_file_form(
+ ZeroBounce *zb,
+ const char *url,
+ char *body,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ char *url_path = strdup(url);
+ if (!url_path) {
+ free(body);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ memory response_data = {0};
+ long http_code = 0;
+ if (!zb_make_request_with_post_body(url_path, body, &response_data, &http_code, error_callback)) {
+ free(response_data.response);
+ free(url_path);
+ return;
+ }
+ p3_send_file_json_success(&response_data, http_code, success_callback, error_callback);
+ free(response_data.response);
+ free(url_path);
+}
+
+static void p3_mime_add_int(curl_mime *multipart, const char *name, int value) {
+ char buf[16];
+ snprintf(buf, sizeof(buf), "%d", value);
+ curl_mimepart *part = curl_mime_addpart(multipart);
+ curl_mime_name(part, name);
+ curl_mime_data(part, buf, CURL_ZERO_TERMINATED);
+}
+
+static void p3_mime_add_optional_bool(curl_mime *multipart, const char *name, int tri_state) {
+ if (tri_state == 1) {
+ curl_mimepart *part = curl_mime_addpart(multipart);
+ curl_mime_name(part, name);
+ curl_mime_data(part, "true", CURL_ZERO_TERMINATED);
+ }
+}
+
+static void p3_bulk_root_multipart(
+ ZeroBounce *zb,
+ const char *url,
+ char *file_path,
+ p3_mime_fields_fn add_fields,
+ void *fields_ctx,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+
+ memory response_data = {0};
+ char *url_path = strdup(url);
+ if (!url_path) {
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+
+ long http_code = 0;
+ zero_bounce_record_request_url_for_test(url_path);
+ if (zb_http_perform_if_mock(&response_data, &http_code, NULL)) {
+ p3_send_file_json_success(&response_data, http_code, success_callback, error_callback);
+ goto cleanup;
+ }
+
+ CURL *curl = curl_easy_init();
+ if (!curl) {
+ if (error_callback) {
+ error_callback(parse_error("Failed to initialize libcurl"));
+ }
+ goto cleanup;
+ }
+
+ curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "POST");
+ curl_easy_setopt(curl, CURLOPT_URL, url_path);
+ struct curl_slist *headers = NULL;
+ headers = curl_slist_append(headers, "Content-Type: multipart/form-data");
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
+
+ curl_mime *multipart = curl_mime_init(curl);
+ curl_mimepart *part = curl_mime_addpart(multipart);
+ curl_mime_name(part, "api_key");
+ curl_mime_data(part, zb->api_key, CURL_ZERO_TERMINATED);
+
+ part = curl_mime_addpart(multipart);
+ curl_mime_name(part, "file");
+ curl_mime_filedata(part, file_path);
+
+ if (add_fields) {
+ add_fields(multipart, fields_ctx);
+ }
+
+ curl_easy_setopt(curl, CURLOPT_MIMEPOST, multipart);
+ set_write_callback(curl, &response_data);
+
+ CURLcode res = curl_easy_perform(curl);
+ if (res != CURLE_OK) {
+ char error_message[300];
+ snprintf(
+ error_message,
+ sizeof(error_message),
+ "Failed to perform request. CURLcode: %d. Error: %s",
+ res,
+ curl_easy_strerror(res)
+ );
+ if (error_callback) {
+ error_callback(parse_error(error_message));
+ }
+ curl_mime_free(multipart);
+ curl_easy_cleanup(curl);
+ curl_slist_free_all(headers);
+ goto cleanup;
+ }
+
+ http_code = get_http_code(curl);
+ curl_mime_free(multipart);
+ curl_easy_cleanup(curl);
+ curl_slist_free_all(headers);
+
+ p3_send_file_json_success(&response_data, http_code, success_callback, error_callback);
+
+cleanup:
+ free(url_path);
+ free(response_data.response);
+}
+
+static void p3_bulk_root_get_file(
+ ZeroBounce *zb,
+ const char *base_url,
+ char *file_id,
+ char *local_download_path,
+ OnSuccessCallbackGetFile success_callback,
+ OnErrorCallback error_callback
+) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+
+ char *api_key_esc = zb_url_escape(zb->api_key);
+ char *file_id_esc = zb_url_escape(file_id);
+ const char *url_pattern = "%s/getfile?api_key=%s&file_id=%s";
+ int url_path_len = snprintf(NULL, 0, url_pattern, base_url, api_key_esc, file_id_esc);
+ char *url_path = malloc((size_t)url_path_len + 1);
+ if (!url_path) {
+ free(api_key_esc);
+ free(file_id_esc);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ snprintf(url_path, (size_t)url_path_len + 1, url_pattern, base_url, api_key_esc, file_id_esc);
+ free(api_key_esc);
+ free(file_id_esc);
+
+ memory response_data = {0};
+ response_data.max_bytes = ZB_MAX_GETFILE_BYTES;
+ long http_code = 0;
+ char *content_type = NULL;
+
+ zero_bounce_record_request_url_for_test(url_path);
+ if (!zb_http_perform_if_mock(&response_data, &http_code, &content_type)) {
+ CURL *curl = curl_easy_init();
+ if (!curl) {
+ if (error_callback) {
+ error_callback(parse_error("Failed to initialize libcurl"));
+ }
+ goto cleanup;
+ }
+ curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "GET");
+ curl_easy_setopt(curl, CURLOPT_URL, url_path);
+ struct curl_slist *headers = NULL;
+ headers = curl_slist_append(headers, "Accept: application/json");
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
+ set_write_callback(curl, &response_data);
+ CURLcode res = curl_easy_perform(curl);
+ if (res != CURLE_OK) {
+ if (error_callback) {
+ error_callback(parse_error(curl_easy_strerror(res)));
+ }
+ curl_easy_cleanup(curl);
+ curl_slist_free_all(headers);
+ goto cleanup;
+ }
+ http_code = get_http_code(curl);
+ content_type = get_content_type_value(curl);
+ curl_easy_cleanup(curl);
+ curl_slist_free_all(headers);
+ }
+
+ if (http_code > 299) {
+ if (error_callback) {
+ error_callback(parse_error(response_data.response));
+ }
+ } else if (zb_get_file_json_indicates_error(response_data.response)) {
+ if (error_callback) {
+ error_callback(parse_error(response_data.response));
+ }
+ } else if (success_callback) {
+ FILE *file_stream = fopen(local_download_path, "wb");
+ if (file_stream == NULL) {
+ if (error_callback) {
+ error_callback(parse_error("Invalid file path"));
+ }
+ } else {
+ fwrite(response_data.response, 1, response_data.size, file_stream);
+ fclose(file_stream);
+ ZBGetFileResponse response = new_zb_get_file_response();
+ response.success = true;
+ response.local_file_path = local_download_path;
+ success_callback(response);
+ }
+ }
+
+cleanup:
+ free(url_path);
+ free(response_data.response);
+ free(content_type);
+}
+
+static void p3_bulk_root_file_status(
+ ZeroBounce *zb,
+ const char *base_url,
+ char *file_id,
+ OnSuccessCallbackFileStatus success_callback,
+ OnErrorCallback error_callback
+) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ char *api_key_esc = zb_url_escape(zb->api_key);
+ char *file_id_esc = zb_url_escape(file_id);
+ const char *url_pattern = "%s/filestatus?api_key=%s&file_id=%s";
+ int url_path_len = snprintf(NULL, 0, url_pattern, base_url, api_key_esc, file_id_esc);
+ char *url_path = malloc((size_t)url_path_len + 1);
+ snprintf(url_path, (size_t)url_path_len + 1, url_pattern, base_url, api_key_esc, file_id_esc);
+ free(api_key_esc);
+ free(file_id_esc);
+
+ memory response_data = {0};
+ long http_code = 0;
+ if (!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)) {
+ free(response_data.response);
+ free(url_path);
+ return;
+ }
+ if (http_code > 299) {
+ if (error_callback) {
+ error_callback(parse_error(response_data.response));
+ }
+ } else if (success_callback) {
+ json_object *j_obj = json_tokener_parse(response_data.response);
+ if (j_obj == NULL) {
+ if (error_callback) {
+ error_callback(parse_error("Failed to parse json string"));
+ }
+ } else {
+ success_callback(zb_file_status_response_from_json(j_obj));
+ json_object_put(j_obj);
+ }
+ }
+ free(response_data.response);
+ free(url_path);
+}
+
+static void p3_bulk_root_delete_file(
+ ZeroBounce *zb,
+ const char *base_url,
+ char *file_id,
+ OnSuccessCallbackDeleteFile success_callback,
+ OnErrorCallback error_callback
+) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ char *api_key_esc = zb_url_escape(zb->api_key);
+ char *file_id_esc = zb_url_escape(file_id);
+ const char *url_pattern = "%s/deletefile?api_key=%s&file_id=%s";
+ int url_path_len = snprintf(NULL, 0, url_pattern, base_url, api_key_esc, file_id_esc);
+ char *url_path = malloc((size_t)url_path_len + 1);
+ snprintf(url_path, (size_t)url_path_len + 1, url_pattern, base_url, api_key_esc, file_id_esc);
+ free(api_key_esc);
+ free(file_id_esc);
+
+ memory response_data = {0};
+ long http_code = 0;
+ if (!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)) {
+ free(response_data.response);
+ free(url_path);
+ return;
+ }
+ if (http_code > 299) {
+ if (error_callback) {
+ error_callback(parse_error(response_data.response));
+ }
+ } else if (success_callback) {
+ json_object *j_obj = json_tokener_parse(response_data.response);
+ if (j_obj == NULL) {
+ if (error_callback) {
+ error_callback(parse_error("Failed to parse json string"));
+ }
+ } else {
+ success_callback(zb_delete_file_response_from_json(j_obj));
+ json_object_put(j_obj);
+ }
+ }
+ free(response_data.response);
+ free(url_path);
+}
+
+typedef struct {
+ int domain_column;
+ const EmailFinderSendFileOptions *options;
+} EmailFinderMimeCtx;
+
+static void p3_email_finder_mime_fields(curl_mime *multipart, void *ctx) {
+ EmailFinderMimeCtx *c = (EmailFinderMimeCtx *)ctx;
+ p3_mime_add_int(multipart, "domain_column", c->domain_column);
+ if (c->options) {
+ p3_mime_add_optional_bool(multipart, "has_header_row", c->options->has_header_row);
+ if (c->options->first_name_column > 0) {
+ p3_mime_add_int(multipart, "first_name_column", c->options->first_name_column);
+ }
+ if (c->options->last_name_column > 0) {
+ p3_mime_add_int(multipart, "last_name_column", c->options->last_name_column);
+ }
+ if (c->options->middle_name_column > 0) {
+ p3_mime_add_int(multipart, "middle_name_column", c->options->middle_name_column);
+ }
+ if (c->options->full_name_column > 0) {
+ p3_mime_add_int(multipart, "full_name_column", c->options->full_name_column);
+ }
+ }
+}
+
+typedef struct {
+ int domain_column;
+ const DomainSearchSendFileOptions *options;
+} DomainSearchMimeCtx;
+
+static void p3_domain_search_mime_fields(curl_mime *multipart, void *ctx) {
+ DomainSearchMimeCtx *c = (DomainSearchMimeCtx *)ctx;
+ p3_mime_add_int(multipart, "domain_column", c->domain_column);
+ if (c->options) {
+ p3_mime_add_optional_bool(multipart, "has_header_row", c->options->has_header_row);
+ }
+}
+
+typedef struct {
+ int email_address_column;
+} ListEvaluatorMimeCtx;
+
+static void p3_list_evaluator_mime_fields(curl_mime *multipart, void *ctx) {
+ ListEvaluatorMimeCtx *c = (ListEvaluatorMimeCtx *)ctx;
+ p3_mime_add_int(multipart, "email_address_column", c->email_address_column);
+}
+
+void send_email_finder_file(
+ ZeroBounce *zb,
+ char *file_path,
+ int domain_column,
+ const EmailFinderSendFileOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ if (domain_column < 1) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: domain_column"));
+ }
+ return;
+ }
+ char url[512];
+ snprintf(url, sizeof(url), "%s/email-finder/sendfile", zb->bulk_api_root_url);
+ EmailFinderMimeCtx ctx = {domain_column, options};
+ p3_bulk_root_multipart(
+ zb, url, file_path, p3_email_finder_mime_fields, &ctx, success_callback, error_callback
+ );
+}
+
+void email_finder_file_status(
+ ZeroBounce *zb,
+ char *file_id,
+ OnSuccessCallbackFileStatus success_callback,
+ OnErrorCallback error_callback
+) {
+ char base[256];
+ snprintf(base, sizeof(base), "%s/email-finder", zb->bulk_api_root_url);
+ p3_bulk_root_file_status(zb, base, file_id, success_callback, error_callback);
+}
+
+void email_finder_get_file(
+ ZeroBounce *zb,
+ char *file_id,
+ char *local_download_path,
+ OnSuccessCallbackGetFile success_callback,
+ OnErrorCallback error_callback
+) {
+ char base[256];
+ snprintf(base, sizeof(base), "%s/email-finder", zb->bulk_api_root_url);
+ p3_bulk_root_get_file(zb, base, file_id, local_download_path, success_callback, error_callback);
+}
+
+void email_finder_delete_file(
+ ZeroBounce *zb,
+ char *file_id,
+ OnSuccessCallbackDeleteFile success_callback,
+ OnErrorCallback error_callback
+) {
+ char base[256];
+ snprintf(base, sizeof(base), "%s/email-finder", zb->bulk_api_root_url);
+ p3_bulk_root_delete_file(zb, base, file_id, success_callback, error_callback);
+}
+
+void send_domain_search_file(
+ ZeroBounce *zb,
+ char *file_path,
+ int domain_column,
+ const DomainSearchSendFileOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ if (domain_column < 1) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: domain_column"));
+ }
+ return;
+ }
+ char url[512];
+ snprintf(url, sizeof(url), "%s/domain-search/sendfile", zb->bulk_api_root_url);
+ DomainSearchMimeCtx ctx = {domain_column, options};
+ p3_bulk_root_multipart(
+ zb, url, file_path, p3_domain_search_mime_fields, &ctx, success_callback, error_callback
+ );
+}
+
+void domain_search_file_status(
+ ZeroBounce *zb,
+ char *file_id,
+ OnSuccessCallbackFileStatus success_callback,
+ OnErrorCallback error_callback
+) {
+ char base[256];
+ snprintf(base, sizeof(base), "%s/domain-search", zb->bulk_api_root_url);
+ p3_bulk_root_file_status(zb, base, file_id, success_callback, error_callback);
+}
+
+void domain_search_get_file(
+ ZeroBounce *zb,
+ char *file_id,
+ char *local_download_path,
+ OnSuccessCallbackGetFile success_callback,
+ OnErrorCallback error_callback
+) {
+ char base[256];
+ snprintf(base, sizeof(base), "%s/domain-search", zb->bulk_api_root_url);
+ p3_bulk_root_get_file(zb, base, file_id, local_download_path, success_callback, error_callback);
+}
+
+void domain_search_delete_file(
+ ZeroBounce *zb,
+ char *file_id,
+ OnSuccessCallbackDeleteFile success_callback,
+ OnErrorCallback error_callback
+) {
+ char base[256];
+ snprintf(base, sizeof(base), "%s/domain-search", zb->bulk_api_root_url);
+ p3_bulk_root_delete_file(zb, base, file_id, success_callback, error_callback);
+}
+
+void send_list_evaluator_file(
+ ZeroBounce *zb,
+ char *file_path,
+ int email_address_column,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ if (email_address_column < 1) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: email_address_column"));
+ }
+ return;
+ }
+ char url[512];
+ snprintf(url, sizeof(url), "%s/listevaluator/", zb->bulk_api_base_url);
+ ListEvaluatorMimeCtx ctx = {email_address_column};
+ p3_bulk_root_multipart(
+ zb, url, file_path, p3_list_evaluator_mime_fields, &ctx, success_callback, error_callback
+ );
+}
+
+void get_list_evaluator_result(
+ ZeroBounce *zb,
+ char *file_id,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ char *api_key_esc = zb_url_escape(zb->api_key);
+ char *file_id_esc = zb_url_escape(file_id);
+ const char *url_pattern = "%s/listevaluator/%s/?api_key=%s";
+ int url_path_len = snprintf(NULL, 0, url_pattern, zb->bulk_api_base_url, file_id_esc, api_key_esc);
+ char *url_path = malloc((size_t)url_path_len + 1);
+ snprintf(url_path, (size_t)url_path_len + 1, url_pattern, zb->bulk_api_base_url, file_id_esc, api_key_esc);
+ free(api_key_esc);
+ free(file_id_esc);
+ p3_raw_json_get(zb, url_path, success_callback, error_callback);
+}
+
+void send_evaluated_file(
+ ZeroBounce *zb,
+ char *file_path,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ char url[512];
+ snprintf(url, sizeof(url), "%s/send-evaluated-file/", zb->bulk_api_base_url);
+ p3_bulk_root_multipart(zb, url, file_path, NULL, NULL, success_callback, error_callback);
+}
+
+void add_filter(
+ ZeroBounce *zb,
+ char *rule,
+ char *target,
+ char *value,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ char *body = NULL;
+ size_t cap = 0;
+ p3_append_form_field(&body, &cap, "api_key", zb->api_key);
+ p3_append_form_field(&body, &cap, "rule", rule ? rule : "");
+ p3_append_form_field(&body, &cap, "target", target ? target : "");
+ p3_append_form_field(&body, &cap, "value", value ? value : "");
+ char url[512];
+ snprintf(url, sizeof(url), "%s/filters/add", zb->api_base_url);
+ p3_raw_json_form_post(zb, strdup(url), body, success_callback, error_callback);
+}
+
+void list_filters(
+ ZeroBounce *zb,
+ bool use_post,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ if (use_post) {
+ char *body = NULL;
+ size_t cap = 0;
+ p3_append_form_field(&body, &cap, "api_key", zb->api_key);
+ char url[512];
+ snprintf(url, sizeof(url), "%s/filters/list", zb->api_base_url);
+ p3_raw_json_form_post(zb, strdup(url), body, success_callback, error_callback);
+ } else {
+ char *api_key_esc = zb_url_escape(zb->api_key);
+ const char *url_pattern = "%s/filters/list?api_key=%s";
+ int url_path_len = snprintf(NULL, 0, url_pattern, zb->api_base_url, api_key_esc);
+ char *url_path = malloc((size_t)url_path_len + 1);
+ snprintf(url_path, (size_t)url_path_len + 1, url_pattern, zb->api_base_url, api_key_esc);
+ free(api_key_esc);
+ p3_raw_json_get(zb, url_path, success_callback, error_callback);
+ }
+}
+
+void delete_filter(
+ ZeroBounce *zb,
+ char *rule,
+ char *target,
+ char *value,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ char *body = NULL;
+ size_t cap = 0;
+ p3_append_form_field(&body, &cap, "api_key", zb->api_key);
+ p3_append_form_field(&body, &cap, "rule", rule ? rule : "");
+ p3_append_form_field(&body, &cap, "target", target ? target : "");
+ p3_append_form_field(&body, &cap, "value", value ? value : "");
+ char url[512];
+ snprintf(url, sizeof(url), "%s/filters/delete", zb->api_base_url);
+ p3_raw_json_form_post(zb, strdup(url), body, success_callback, error_callback);
+}
+
+static char *p3_build_validate_s3_body(ZeroBounce *zb, const ValidateS3FileOptions *options) {
+ char *body = NULL;
+ size_t cap = 0;
+ p3_append_form_field(&body, &cap, "file_name", options->file_name ? options->file_name : "");
+ p3_append_optional_bool_form(&body, &cap, "has_header_row", options->has_header_row);
+ p3_append_optional_bool_form(&body, &cap, "remove_duplicates", options->remove_duplicates);
+ p3_append_optional_bool_form(&body, &cap, "remove_duplicate", options->remove_duplicate);
+ if (options->email_address_column > 0) {
+ char col[16];
+ snprintf(col, sizeof(col), "%d", options->email_address_column);
+ p3_append_form_field(&body, &cap, "email_address_column", col);
+ }
+ if (options->return_url && options->return_url[0]) {
+ p3_append_form_field(&body, &cap, "return_url", options->return_url);
+ }
+ if (options->first_name_column > 0) {
+ char col[16];
+ snprintf(col, sizeof(col), "%d", options->first_name_column);
+ p3_append_form_field(&body, &cap, "first_name_column", col);
+ }
+ if (options->last_name_column > 0) {
+ char col[16];
+ snprintf(col, sizeof(col), "%d", options->last_name_column);
+ p3_append_form_field(&body, &cap, "last_name_column", col);
+ }
+ if (options->gender_column > 0) {
+ char col[16];
+ snprintf(col, sizeof(col), "%d", options->gender_column);
+ p3_append_form_field(&body, &cap, "gender_column", col);
+ }
+ if (options->ip_address_column > 0) {
+ char col[16];
+ snprintf(col, sizeof(col), "%d", options->ip_address_column);
+ p3_append_form_field(&body, &cap, "ip_address_column", col);
+ }
+ if (options->allow_phase_2_is_set) {
+ p3_append_form_field(&body, &cap, "allow_phase_2", options->allow_phase_2 ? "true" : "false");
+ }
+ p3_append_form_field(&body, &cap, "api_key", zb->api_key);
+ return body;
+}
+
+void validate_s3_file(
+ ZeroBounce *zb,
+ const ValidateS3FileOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ if (!options || !options->file_name || !options->file_name[0]) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: file_name"));
+ }
+ return;
+ }
+ char *body = p3_build_validate_s3_body(zb, options);
+ char url[512];
+ snprintf(url, sizeof(url), "%s/validateS3file", zb->bulk_api_root_url);
+ p3_post_send_file_form(zb, url, body, success_callback, error_callback);
+}
+
+void score_s3_file(
+ ZeroBounce *zb,
+ const ScoreS3FileOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ if (!options || !options->file_name || !options->file_name[0]) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: file_name"));
+ }
+ return;
+ }
+ char *body = NULL;
+ size_t cap = 0;
+ p3_append_form_field(&body, &cap, "file_name", options->file_name);
+ p3_append_form_field(&body, &cap, "api_key", zb->api_key);
+ p3_append_optional_bool_form(&body, &cap, "has_header_row", options->has_header_row);
+ p3_append_optional_bool_form(&body, &cap, "remove_duplicates", options->remove_duplicates);
+ p3_append_optional_bool_form(&body, &cap, "remove_duplicate", options->remove_duplicate);
+ if (options->email_address_column > 0) {
+ char col[16];
+ snprintf(col, sizeof(col), "%d", options->email_address_column);
+ p3_append_form_field(&body, &cap, "email_address_column", col);
+ }
+ char url[512];
+ snprintf(url, sizeof(url), "%s/scoreS3file", zb->bulk_api_root_url);
+ p3_post_send_file_form(zb, url, body, success_callback, error_callback);
+}
+
+void export_s3_file(
+ ZeroBounce *zb,
+ char *file_id,
+ char *export_bucket,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ if (!file_id || !file_id[0]) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: file_id"));
+ }
+ return;
+ }
+ if (!export_bucket || !export_bucket[0]) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: export_bucket"));
+ }
+ return;
+ }
+ char *body = NULL;
+ size_t cap = 0;
+ p3_append_form_field(&body, &cap, "file_id", file_id);
+ p3_append_form_field(&body, &cap, "export_bucket", export_bucket);
+ p3_append_form_field(&body, &cap, "api_key", zb->api_key);
+ char url[512];
+ snprintf(url, sizeof(url), "%s/exportS3file", zb->bulk_api_root_url);
+ p3_post_send_file_form(zb, url, body, success_callback, error_callback);
+}
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index 892e8c9..d0cfeca 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -19,6 +19,7 @@
#include
#include "ZeroBounce/ZeroBounce.h"
+#include "ZeroBounce/zb_internal.h"
static ZBHttpPerformFn s_http_perform = NULL;
static char s_last_request_url_for_test[1024] = {0};
@@ -28,6 +29,13 @@ void zero_bounce_set_http_perform_for_test(ZBHttpPerformFn fn) {
s_http_perform = fn;
}
+int zb_http_perform_if_mock(memory *response_data, long *http_code, char **content_type_out) {
+ if (!s_http_perform) {
+ return 0;
+ }
+ return s_http_perform(response_data, http_code, content_type_out);
+}
+
const char* zero_bounce_get_last_request_url_for_test(void) {
return s_last_request_url_for_test[0] ? s_last_request_url_for_test : NULL;
}
@@ -36,7 +44,7 @@ const char* zero_bounce_get_last_post_body_for_test(void) {
return s_last_post_body_for_test[0] ? s_last_post_body_for_test : NULL;
}
-static void zero_bounce_record_request_url_for_test(const char* url) {
+void zero_bounce_record_request_url_for_test(const char* url) {
if (!url) {
s_last_request_url_for_test[0] = '\0';
return;
@@ -45,7 +53,7 @@ static void zero_bounce_record_request_url_for_test(const char* url) {
s_last_request_url_for_test[sizeof(s_last_request_url_for_test) - 1] = '\0';
}
-static char* zb_url_escape(const char* value) {
+char* zb_url_escape(const char* value) {
char* escaped = curl_easy_escape(NULL, value ? value : "", 0);
char* copy = escaped ? strdup(escaped) : NULL;
curl_free(escaped);
@@ -403,6 +411,7 @@ ZeroBounce* new_zero_bounce_instance() {
zb->api_key = NULL;
zb->api_base_url = "https://api.zerobounce.net/v2";
+ zb->bulk_api_root_url = "https://bulkapi.zerobounce.net";
zb->bulk_api_base_url = "https://bulkapi.zerobounce.net/v2";
zb->bulk_api_scoring_base_url = "https://bulkapi.zerobounce.net/v2/scoring";
@@ -436,7 +445,7 @@ void zero_bounce_initialize_with_base_url(ZeroBounce* zb, const char* api_key, Z
}
-static bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback) {
+bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback) {
if (zb->api_key == NULL || strlen(zb->api_key) == 0) {
ZBErrorResponse error_response = parse_error(
"ZeroBounce is not initialized. Please call zero_bounce_initialize(zb_instance, api_key); first"
@@ -448,7 +457,7 @@ static bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_ca
}
-static int make_request_with_post_body(
+int zb_make_request_with_post_body(
char* url_path,
char* body,
memory* response_data,
@@ -505,7 +514,7 @@ static int make_request_with_post_body(
return 1;
}
-static int make_request(
+int zb_make_request(
char* url_path,
char* request_type,
char* header,
@@ -513,6 +522,7 @@ static int make_request(
long* http_code,
OnErrorCallback error_callback
) {
+ zero_bounce_record_request_url_for_test(url_path);
if (s_http_perform) {
return s_http_perform(response_data, http_code, NULL);
}
@@ -771,7 +781,7 @@ static void file_status_internal(
memory response_data = {0};
long http_code;
- if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
+ if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
goto cleanup;
}
@@ -979,7 +989,7 @@ static void delete_file_internal(
memory response_data = {0};
long http_code;
- if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
+ if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
goto cleanup;
}
@@ -1037,7 +1047,7 @@ static void find_email_internal(
memory response_data = {0};
long http_code;
- if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
+ if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
goto cleanup;
}
@@ -1091,7 +1101,7 @@ static void search_domain_internal(
memory response_data = {0};
long http_code;
- if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
+ if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
goto cleanup;
}
@@ -1149,7 +1159,7 @@ void get_credits(
memory response_data = {0};
long http_code;
- if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
+ if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
goto cleanup;
}
@@ -1220,7 +1230,7 @@ void get_api_usage(
memory response_data = {0};
long http_code;
- if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
+ if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
goto cleanup;
}
@@ -1345,7 +1355,7 @@ void validate_email_with_options(
memory response_data = {0};
long http_code;
- if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
+ if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
goto cleanup;
}
@@ -1666,7 +1676,7 @@ void get_activity_data(
memory response_data = {0};
long http_code;
- if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
+ if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
goto cleanup;
}
@@ -1746,8 +1756,8 @@ static void score_email_internal(
memory response_data = {0};
long http_code = 0;
int ok = use_post
- ? make_request_with_post_body(url_path, post_body, &response_data, &http_code, error_callback)
- : make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback);
+ ? zb_make_request_with_post_body(url_path, post_body, &response_data, &http_code, error_callback)
+ : zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback);
if (!ok) {
goto cleanup;
@@ -1916,7 +1926,7 @@ void find_email(
memory response_data = {0};
long http_code;
- if(!make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
+ if(!zb_make_request(url_path, "GET", "Accept: application/json", &response_data, &http_code, error_callback)){
goto cleanup;
}
diff --git a/test/ZeroBounceTest.c b/test/ZeroBounceTest.c
index aa7d1e6..7d517d1 100644
--- a/test/ZeroBounceTest.c
+++ b/test/ZeroBounceTest.c
@@ -944,6 +944,27 @@ void test_score_email_valid(void) {
TEST_ASSERT_NOT_NULL(strstr(url, "email="));
}
+void test_validate_s3_file_valid(void) {
+ response_json = "{\"success\":true,\"file_id\":\"s3-1\",\"message\":\"\"}";
+
+ expected_send_file_response = zb_send_file_response_from_json(
+ json_tokener_parse(response_json)
+ );
+
+ get_http_code_fake.return_val = 200;
+ ValidateS3FileOptions options = new_validate_s3_file_options();
+ options.file_name = "bucket/key.csv";
+ validate_s3_file(zb, &options, on_success_send_file_valid, on_error_valid);
+
+ const char* url = zero_bounce_get_last_request_url_for_test();
+ TEST_ASSERT_NOT_NULL(url);
+ TEST_ASSERT_NOT_NULL(strstr(url, "/validateS3file"));
+
+ const char* body = zero_bounce_get_last_post_body_for_test();
+ TEST_ASSERT_NOT_NULL(body);
+ TEST_ASSERT_NOT_NULL(strstr(body, "file_name="));
+}
+
void test_score_email_post_valid(void) {
response_json = "{\"email\":\"mark@gmail.com\",\"score\":10}";
@@ -1046,6 +1067,7 @@ int main(void)
RUN_TEST(test_scoring_delete_file_valid);
RUN_TEST(test_score_email_valid);
RUN_TEST(test_score_email_post_valid);
+ RUN_TEST(test_validate_s3_file_valid);
RUN_TEST(test_find_email_status_invalid_payload);
RUN_TEST(test_find_email_status_valid_payload);
RUN_TEST(test_search_domain_status_invalid_payload);
From 91754c3998c14b4bd5e2293f39f26684996b4f7b 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 07/19] 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 | 2 ++
src/ZeroBounce.c | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h
index 0009ed4..cbb26ee 100644
--- a/include/ZeroBounce/ZeroBounce.h
+++ b/include/ZeroBounce/ZeroBounce.h
@@ -93,6 +93,8 @@ typedef struct {
int timeout;
int activity_data;
int verify_plus;
+ /** -1 omit, 1 use form-encoded POST for /validate */
+ int use_post;
} ZBValidateOptions;
/**
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index d0cfeca..44d6b57 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -171,7 +171,7 @@ GetFileOptions new_get_file_options(void) {
}
ZBValidateOptions new_validate_options(void) {
- ZBValidateOptions o = { -1, -1, -1 };
+ ZBValidateOptions o = { -1, -1, -1, -1 };
return o;
}
From 364ad76aaa7f771a3eb9d17323a181986442a1a6 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 03:25:56 +0400
Subject: [PATCH 08/19] feat(validate): POST /validate form body and
list_s3_files
Wire ZBValidateOptions.use_post for validate_email_with_options and add list_s3_files Unity tests.
Co-authored-by: Cursor
---
include/ZeroBounce/ZeroBounce.h | 8 +++
src/Priority3.c | 62 ++++++++++++++++++
src/ZeroBounce.c | 108 ++++++++++++++++++++++++++++++++
test/ZeroBounceTest.c | 60 ++++++++++++++++++
4 files changed, 238 insertions(+)
diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h
index cbb26ee..f57a579 100644
--- a/include/ZeroBounce/ZeroBounce.h
+++ b/include/ZeroBounce/ZeroBounce.h
@@ -902,6 +902,14 @@ void export_s3_file(
OnErrorCallback error_callback
);
+void list_s3_files(
+ ZeroBounce *zb,
+ const char *bucket,
+ const char *search,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
/**
* @brief Email Address Search - Identifies and validates a person’s primary email address
*
diff --git a/src/Priority3.c b/src/Priority3.c
index 7b1e28f..a59a888 100644
--- a/src/Priority3.c
+++ b/src/Priority3.c
@@ -857,6 +857,68 @@ void score_s3_file(
p3_post_send_file_form(zb, url, body, success_callback, error_callback);
}
+void list_s3_files(
+ ZeroBounce *zb,
+ const char *bucket,
+ const char *search,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ char *api_key_esc = zb_url_escape(zb->api_key);
+ char *bucket_esc = (bucket && bucket[0]) ? zb_url_escape(bucket) : NULL;
+ char *search_esc = (search && search[0]) ? zb_url_escape(search) : NULL;
+ if (!api_key_esc) {
+ free(bucket_esc);
+ free(search_esc);
+ if (error_callback) {
+ error_callback(parse_error("Memory allocation failed"));
+ }
+ return;
+ }
+ const char *url_pattern = "%s/listS3files?api_key=%s%s%s%s%s";
+ const char *bucket_part = bucket_esc ? "&bucket=" : "";
+ const char *search_part = search_esc ? "&search=" : "";
+ int url_path_len = snprintf(
+ NULL,
+ 0,
+ url_pattern,
+ zb->bulk_api_root_url,
+ api_key_esc,
+ bucket_part,
+ bucket_esc ? bucket_esc : "",
+ search_part,
+ search_esc ? search_esc : ""
+ );
+ char *url_path = malloc((size_t)url_path_len + 1);
+ if (!url_path) {
+ free(api_key_esc);
+ free(bucket_esc);
+ free(search_esc);
+ if (error_callback) {
+ error_callback(parse_error("Memory allocation failed"));
+ }
+ return;
+ }
+ snprintf(
+ url_path,
+ (size_t)url_path_len + 1,
+ url_pattern,
+ zb->bulk_api_root_url,
+ api_key_esc,
+ bucket_part,
+ bucket_esc ? bucket_esc : "",
+ search_part,
+ search_esc ? search_esc : ""
+ );
+ free(api_key_esc);
+ free(bucket_esc);
+ free(search_esc);
+ p3_raw_json_get(zb, url_path, success_callback, error_callback);
+}
+
void export_s3_file(
ZeroBounce *zb,
char *file_id,
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index 44d6b57..9068dbf 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -1285,6 +1285,114 @@ void validate_email_with_options(
return;
}
+ if (options->use_post == 1) {
+ char *url_path = malloc(strlen(zb->api_base_url) + 16);
+ if (!url_path) {
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ snprintf(url_path, strlen(zb->api_base_url) + 16, "%s/validate", zb->api_base_url);
+
+ char *api_key_esc = zb_url_escape(zb->api_key);
+ char *email_esc = zb_url_escape(email ? email : "");
+ char *ip_esc = zb_url_escape(ip_address ? ip_address : "");
+ if (!api_key_esc || !email_esc || !ip_esc) {
+ free(api_key_esc);
+ free(email_esc);
+ free(ip_esc);
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+
+ size_t body_cap = strlen(api_key_esc) + strlen(email_esc) + strlen(ip_esc) + 128;
+ if (options->timeout >= 0) {
+ body_cap += 32;
+ }
+ if (options->activity_data >= 0 || options->verify_plus >= 0) {
+ body_cap += 64;
+ }
+ char *post_body = malloc(body_cap);
+ if (!post_body) {
+ free(api_key_esc);
+ free(email_esc);
+ free(ip_esc);
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ snprintf(
+ post_body, body_cap, "api_key=%s&email=%s&ip_address=%s",
+ api_key_esc, email_esc, ip_esc
+ );
+ free(api_key_esc);
+ free(email_esc);
+ free(ip_esc);
+
+ if (options->timeout >= 0) {
+ char timeout_buf[16];
+ snprintf(timeout_buf, sizeof(timeout_buf), "%d", options->timeout);
+ char *timeout_esc = zb_url_escape(timeout_buf);
+ if (!timeout_esc || !zb_url_append_suffix(&post_body, "&timeout=") ||
+ !zb_url_append_suffix(&post_body, timeout_esc)) {
+ free(timeout_esc);
+ free(post_body);
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ free(timeout_esc);
+ }
+ if (options->activity_data >= 0) {
+ const char *suffix = options->activity_data ? "&activity_data=true" : "&activity_data=false";
+ if (!zb_url_append_suffix(&post_body, suffix)) {
+ free(post_body);
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ }
+ if (options->verify_plus >= 0) {
+ const char *suffix = options->verify_plus ? "&verify_plus=true" : "&verify_plus=false";
+ if (!zb_url_append_suffix(&post_body, suffix)) {
+ free(post_body);
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ }
+
+ zero_bounce_record_request_url_for_test(url_path);
+
+ memory response_data = {0};
+ long http_code;
+
+ if (!zb_make_request_with_post_body(url_path, post_body, &response_data, &http_code, error_callback)) {
+ free(url_path);
+ free(post_body);
+ return;
+ }
+
+ if (http_code > 299) {
+ if (error_callback) {
+ error_callback(parse_error(response_data.response));
+ }
+ } else if (success_callback) {
+ json_object *j_obj = json_tokener_parse(response_data.response);
+ if (j_obj == NULL) {
+ error_callback(parse_error("Failed to parse json string"));
+ } else {
+ success_callback(zb_validate_response_from_json(j_obj));
+ json_object_put(j_obj);
+ }
+ }
+
+ free(url_path);
+ free(post_body);
+ free(response_data.response);
+ return;
+ }
+
char* api_key_esc = zb_url_escape(zb->api_key);
char* email_esc = zb_url_escape(email ? email : "");
char* ip_esc = zb_url_escape(ip_address ? ip_address : "");
diff --git a/test/ZeroBounceTest.c b/test/ZeroBounceTest.c
index 7d517d1..1917bd4 100644
--- a/test/ZeroBounceTest.c
+++ b/test/ZeroBounceTest.c
@@ -109,6 +109,19 @@ void on_success_score_email_valid(ZBScoreEmailResponse response) {
TEST_ASSERT_EQUAL_INT(1, compare_zb_score_email_response(&expected_score_email_response, &response));
}
+static char last_raw_json_buffer[4096];
+static const char *last_raw_json_response = NULL;
+
+void on_success_raw_json(const char *json_response) {
+ if (!json_response) {
+ last_raw_json_buffer[0] = '\0';
+ } else {
+ strncpy(last_raw_json_buffer, json_response, sizeof(last_raw_json_buffer) - 1);
+ last_raw_json_buffer[sizeof(last_raw_json_buffer) - 1] = '\0';
+ }
+ last_raw_json_response = last_raw_json_buffer;
+}
+
void on_success_score_email_invalid(ZBScoreEmailResponse response) {
TEST_FAIL_MESSAGE(zb_score_email_response_to_string(&response));
}
@@ -400,6 +413,51 @@ void test_validate_email_with_options_query_params(void)
TEST_ASSERT_NOT_NULL(strstr(url, "verify_plus=false"));
}
+void test_validate_email_with_options_post_form(void)
+{
+ response_json = "{\"address\":\"valid@example.com\",\"status\":\"valid\",\"sub_status\":\"\",\"processed_at\":\"2023-04-05 13:29:47.553\"}";
+
+ get_http_code_fake.return_val = 200;
+
+ expected_validate_response = zb_validate_response_from_json(json_tokener_parse(response_json));
+
+ ZBValidateOptions options = new_validate_options();
+ options.use_post = 1;
+ options.timeout = 30;
+
+ validate_email_with_options(
+ zb, "valid@example.com", "", &options, on_success_validate_valid, on_error_valid
+ );
+
+ const char* url = zero_bounce_get_last_request_url_for_test();
+ TEST_ASSERT_NOT_NULL(url);
+ TEST_ASSERT_NOT_NULL(strstr(url, "/validate"));
+
+ const char* body = zero_bounce_get_last_post_body_for_test();
+ TEST_ASSERT_NOT_NULL(body);
+ TEST_ASSERT_NOT_NULL(strstr(body, "api_key="));
+ TEST_ASSERT_NOT_NULL(strstr(body, "email="));
+ TEST_ASSERT_NOT_NULL(strstr(body, "timeout=30"));
+}
+
+void test_list_s3_files_valid(void)
+{
+ response_json = "[{\"name\":\"uploads/list.csv\",\"size\":100,\"last_modified\":\"2024-01-01T00:00:00Z\"}]";
+ last_raw_json_response = NULL;
+
+ get_http_code_fake.return_val = 200;
+
+ list_s3_files(zb, "my-bucket", "list", on_success_raw_json, on_error_valid);
+
+ const char* url = zero_bounce_get_last_request_url_for_test();
+ TEST_ASSERT_NOT_NULL(url);
+ TEST_ASSERT_NOT_NULL(strstr(url, "/listS3files"));
+ TEST_ASSERT_NOT_NULL(strstr(url, "bucket=my-bucket"));
+ TEST_ASSERT_NOT_NULL(strstr(url, "search=list"));
+ TEST_ASSERT_NOT_NULL(last_raw_json_response);
+ TEST_ASSERT_NOT_NULL(strstr(last_raw_json_response, "uploads/list.csv"));
+}
+
void test_validate_email_batch_with_options_body(void)
{
response_json = "{\"email_batch\":[],\"errors\":[]}";
@@ -1042,6 +1100,7 @@ int main(void)
RUN_TEST(test_validate_email_invalid);
RUN_TEST(test_validate_email_valid);
RUN_TEST(test_validate_email_with_options_query_params);
+ RUN_TEST(test_validate_email_with_options_post_form);
RUN_TEST(test_validate_email_batch_invalid);
RUN_TEST(test_validate_email_batch_uses_api_url);
RUN_TEST(test_validate_email_batch_with_options_body);
@@ -1068,6 +1127,7 @@ int main(void)
RUN_TEST(test_score_email_valid);
RUN_TEST(test_score_email_post_valid);
RUN_TEST(test_validate_s3_file_valid);
+ RUN_TEST(test_list_s3_files_valid);
RUN_TEST(test_find_email_status_invalid_payload);
RUN_TEST(test_find_email_status_valid_payload);
RUN_TEST(test_search_domain_status_invalid_payload);
From 2e087b0c0f08cedcd02ee61fd562e7a6f5595a4f Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Mon, 19 Oct 2026 06:29:35 +0400
Subject: [PATCH 09/19] fix: return short API errors and cover every call
offline
HTML pages, empty bodies, and account-level batch failures were reaching callers as raw payloads or successes. Record each request in the test hook so the default Unity run checks endpoints without the network.
Co-authored-by: Cursor
---
AGENTS.md | 57 ++
CHANGELOG.md | 106 +++
CMakeLists.txt | 2 +-
include/ZeroBounce/ZBPriority3Options.h | 2 +-
include/ZeroBounce/ZeroBounce.h | 11 +
include/ZeroBounce/zb_internal.h | 9 +
src/Priority3.c | 109 +--
src/ZBErrorResponse.c | 248 ++++++-
src/ZeroBounce.c | 269 ++++---
test/OfflineApiTest.c | 909 ++++++++++++++++++++++++
test/ZeroBounceTest.c | 4 +
11 files changed, 1576 insertions(+), 150 deletions(-)
create mode 100644 AGENTS.md
create mode 100644 CHANGELOG.md
create mode 100644 test/OfflineApiTest.c
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..1afb75e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,57 @@
+# AGENTS.md
+
+Guidance for AI coding agents working in this repository.
+
+## Project
+
+`zerobounce-c-api-wrapper` is the ZeroBounce C SDK. Callers allocate a `ZeroBounce` with `new_zero_bounce_instance`, call `zero_bounce_initialize`, and receive results through success and error callbacks. HTTP goes through libcurl in `src/ZeroBounce.c` and `src/Priority3.c`. There is no package registry: releases are GitHub tags. This is not the C++ repository `zerobounce-cpp-api-wrapper`.
+
+## Layout
+
+- `include/ZeroBounce/`: public headers. `ZeroBounce.h` is the client.
+- `src/ZeroBounce.c`: validation, bulk file, credits, activity, scoring, and the libcurl write callback.
+- `src/Priority3.c`: bulk email finder, domain search, list evaluator, filters, S3.
+- `src/ZBErrorResponse.c`: turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short `ZBErrorResponse`.
+- `test/ZeroBounceTest.c`: existing Unity tests.
+- `test/OfflineApiTest.c`: offline suite. `zero_bounce_set_http_perform_for_test` replaces libcurl and records every request.
+- `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
+./build/bin/ZeroBounceTests
+```
+
+CI (`.github/workflows/sdk_ci.yml`) configures, builds, and runs `./build/bin/ZeroBounceTests`. That build is the typecheck. `build/` is generated; do not commit it.
+
+## Conventions
+
+- **One `ZeroBounce` per API key.** `zero_bounce_get_instance()` is a process-wide singleton. Construct a separate object with `new_zero_bounce_instance` per key. The offline concurrency test does this.
+- **Column indexes are 1-based** on bulk send-file name, gender, and IP columns. `0` means the field is left out. `send_file`'s email column is required and is sent as given, including `0`. `ValidateS3FileOptions.email_address_column` uses `-1` for omit, and `0` is sent.
+- **Tri-state ints are `-1` omit, `0` false, `1` true.** `timeout`, `activity_data`, `verify_plus`, `has_header_row`, and `allow_phase_2` follow that. Explicit `0` is sent as `false`.
+- **Booleans on multipart and form bodies are the strings `true` and `false`.**
+- **`allow_phase_2` is validation `send_file` only**, and only when `allow_phase_2_is_set` is true. Scoring `send_file` ignores it. `activity_data` on get-file is validation only.
+- **`validate_email_batch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. An empty batch IP is JSON `null`.
+- **Failures use the error callback**, not a process exit. The message must be short: no raw HTML, no megabyte body, and no `Error: Error:` prefix. An account-level `validate_email_batch` error (`email_address` `all`) is an error callback. A per-address error stays on the success result.
+- **Tests replace HTTP** with `zero_bounce_set_http_perform_for_test`. Read `zero_bounce_test_method`, `zero_bounce_test_url`, `zero_bounce_test_body`, and `zero_bounce_test_field_*` from that callback. They are thread-local. 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 function, 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 function 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`. That is the only version field. There is no lockfile and no runtime version constant.
+3. `cmake --build build` and `./build/bin/ZeroBounceTests`.
+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..2d7dcf1
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,106 @@
+# 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_email_with_options` and `validate_email_batch_with_options` take `ZBValidateOptions`: `timeout`, `activity_data`, `verify_plus`, and `use_post` (form-encoded `POST /validate`).
+- `ZBValidateResponse` reads `active_in_days` and `active_first_seen`.
+- `score_email` (`GET /v2/scoring`) and `score_email_post` (form-encoded `POST /v2/scoring`).
+- Bulk email finder (`send_email_finder_file`, `email_finder_file_status`, `email_finder_get_file`, `email_finder_delete_file`).
+- Bulk domain search (`send_domain_search_file`, `domain_search_file_status`, `domain_search_get_file`, `domain_search_delete_file`).
+- List evaluator (`send_list_evaluator_file`, `get_list_evaluator_result`, `send_evaluated_file`).
+- Filters (`add_filter`, `list_filters`, `delete_filter`).
+- S3 bulk (`validate_s3_file`, `score_s3_file`, `export_s3_file`, `list_s3_files`).
+
+### Changed
+
+- API requests time out after 120 seconds.
+- Query values, including the API key, email, and file id, are percent-encoded.
+- JSON API responses are capped at 16 MB and bulk file downloads at 256 MB.
+
+### Fixed
+
+- A 403 HTML "Access denied" page is returned as a short error. An HTML page with status 200 is an error as well, including `get_file`, `email_finder_get_file`, and `domain_search_get_file`, instead of a saved file.
+- Empty and non-JSON bodies are a short error. A repeated `Error:` / `[Error]:` prefix is removed.
+- `validate_email_batch` 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.
+- Bulk email finder and domain search send `has_header_row` when it is set to false.
+- `validate_s3_file` sends `email_address_column` when it is set to 0. `validate_s3_file`, `score_s3_file`, and `export_s3_file` reject an empty API key before a request is sent.
+- `validate_email_batch` sends an empty IP address as JSON `null`.
+
+### Security
+
+- Query parameters are percent-encoded, so a value cannot rewrite the request URL.
+- Error callbacks no longer include the raw HTML access-denied page or a multi-megabyte body.
+
+## [2.1.3] - 2026-06-18
+
+### Fixed
+
+- `validate_email_batch` posts to `api.zerobounce.net/v2/validatebatch` instead of `bulkapi.zerobounce.net`.
+
+## [2.1.2] - 2026-06-10
+
+### Added
+
+- `ZBValidateResponse.catchall_domain`, read from `catchall_domain`.
+
+## [2.1.0] - 2026-04-05
+
+### Added
+
+- `get_file_with_options` and `scoring_get_file_with_options`, with `GetFileOptions.download_type` (`phase_1`, `phase_2`, `combined`) and `activity_data` (validation `get_file` only).
+- `SendFileOptions.allow_phase_2` / `allow_phase_2_is_set`, sent as `allow_phase_2` on validation `send_file` only.
+- `ZBFileStatusResponse.file_phase_2_status`.
+
+### Fixed
+
+- `get_file` 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
+
+- `find_email_by_domain_*`, `find_email_by_company_name_*`, `search_domain_by_domain`, and `search_domain_by_company_name`.
+- `zero_bounce_initialize_with_base_url` to select the API base URL.
+- `ZBValidateSubStatus` value `AcceptAll`.
+
+### Changed
+
+- The library builds as C17 and uses libcurl for HTTP.
+
+### Deprecated
+
+- `find_email`. Use the email-finder or domain-search functions instead.
+
+## [1.1.0] - 2023-09-07
+
+### Added
+
+- Email finder response types and `find_email`.
+
+## [1.0.0] - 2023-08-22
+
+### Added
+
+- Initial release: `get_credits`, `get_api_usage`, `validate_email`, `validate_email_batch`, bulk and AI-scoring file functions, and `get_activity_data`.
+
+[Unreleased]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v2.1.4...HEAD
+[2.1.3]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v2.1.2...v2.1.3
+[2.1.2]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v2.1.0...v2.1.2
+[2.1.0]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v2.0.10...v2.1.0
+[2.0.10]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v2.0.0...v2.0.10
+[2.0.0]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v1.1.0...v2.0.0
+[1.1.0]: https://github.com/zerobounce/zerobounce-c-api-wrapper/compare/v1.0.0...v1.1.0
+[1.0.0]: https://github.com/zerobounce/zerobounce-c-api-wrapper/releases/tag/v1.0.0
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 3def030..d13fd48 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -57,6 +57,6 @@ target_include_directories(${PROJECT_NAME} PUBLIC include ${json-c_SOURCE_DIR})
target_link_libraries(${PROJECT_NAME} PUBLIC json-c libcurl)
enable_testing()
-add_executable(ZeroBounceTests test/ZeroBounceTest.c)
+add_executable(ZeroBounceTests test/ZeroBounceTest.c test/OfflineApiTest.c)
target_link_libraries(ZeroBounceTests PRIVATE ${PROJECT_NAME} unity)
target_include_directories(ZeroBounceTests PRIVATE ${fff_SOURCE_DIR})
diff --git a/include/ZeroBounce/ZBPriority3Options.h b/include/ZeroBounce/ZBPriority3Options.h
index 8c7b7cf..f43156b 100644
--- a/include/ZeroBounce/ZBPriority3Options.h
+++ b/include/ZeroBounce/ZBPriority3Options.h
@@ -5,7 +5,7 @@
/**
* Optional columns for bulk email finder sendfile.
- * Column indexes start at 1. has_header_row: -1 omit, 1 sends "true".
+ * Column indexes start at 1. has_header_row: -1 omit, 0 sends "false", 1 sends "true".
*/
typedef struct {
int first_name_column;
diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h
index f57a579..b156184 100644
--- a/include/ZeroBounce/ZeroBounce.h
+++ b/include/ZeroBounce/ZeroBounce.h
@@ -951,4 +951,15 @@ const char* zero_bounce_get_last_request_url_for_test(void);
*/
const char* zero_bounce_get_last_post_body_for_test(void);
+/**
+ * Thread-local snapshot of the request about to be performed.
+ * Safe to read from the HTTP test hook on the same thread.
+ */
+const char *zero_bounce_test_method(void);
+const char *zero_bounce_test_url(void);
+const char *zero_bounce_test_body(void);
+int zero_bounce_test_field_count(void);
+const char *zero_bounce_test_field_name(int index);
+const char *zero_bounce_test_field_value(int index);
+
#endif
\ No newline at end of file
diff --git a/include/ZeroBounce/zb_internal.h b/include/ZeroBounce/zb_internal.h
index 349bc75..e062e29 100644
--- a/include/ZeroBounce/zb_internal.h
+++ b/include/ZeroBounce/zb_internal.h
@@ -21,6 +21,15 @@ int zb_make_request_with_post_body(
OnErrorCallback error_callback
);
void zero_bounce_record_request_url_for_test(const char *url);
+void zb_note_http_request(const char *method, const char *url, const char *body);
+void zb_note_http_field(const char *name, const char *value);
int zb_http_perform_if_mock(memory *response_data, long *http_code, char **content_type_out);
+char *zb_message_from_body(const char *body, long status_code);
+bool zb_body_looks_like_html(const char *body);
+void zb_report_body_error(const char *body, long status_code, OnErrorCallback error_callback);
+/** Parses a 2xx non-HTML JSON body. On failure, reports a short error and returns false. */
+bool zb_read_success_json(const char *body, long status_code, json_object **out, OnErrorCallback error_callback);
+bool zb_batch_has_account_error(const json_object *batch, char **message_out);
+
#endif
diff --git a/src/Priority3.c b/src/Priority3.c
index a59a888..b467f26 100644
--- a/src/Priority3.c
+++ b/src/Priority3.c
@@ -100,14 +100,24 @@ static void p3_send_json_success(
OnSuccessCallbackRawJson success_callback,
OnErrorCallback error_callback
) {
- if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data->response));
+ const char *body = response_data->response ? response_data->response : "";
+ if (http_code > 299 || zb_body_looks_like_html(body) || body[0] == '\0') {
+ zb_report_body_error(body, http_code, error_callback);
+ return;
+ }
+ if (body[0] == '{' || body[0] == '[') {
+ json_object *parsed = json_tokener_parse(body);
+ if (!parsed) {
+ zb_report_body_error(body, http_code, error_callback);
+ return;
}
+ json_object_put(parsed);
+ } else {
+ zb_report_body_error(body, http_code, error_callback);
return;
}
if (success_callback) {
- success_callback(response_data->response ? response_data->response : "");
+ success_callback(body);
}
}
@@ -156,14 +166,11 @@ static void p3_send_file_json_success(
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
- if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data->response));
- }
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data->response, http_code, &j_obj, error_callback)) {
return;
}
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data->response);
if (j_obj == NULL) {
if (error_callback) {
error_callback(parse_error("Failed to parse json string"));
@@ -203,17 +210,23 @@ static void p3_post_send_file_form(
static void p3_mime_add_int(curl_mime *multipart, const char *name, int value) {
char buf[16];
snprintf(buf, sizeof(buf), "%d", value);
+ zb_note_http_field(name, buf);
curl_mimepart *part = curl_mime_addpart(multipart);
curl_mime_name(part, name);
curl_mime_data(part, buf, CURL_ZERO_TERMINATED);
}
static void p3_mime_add_optional_bool(curl_mime *multipart, const char *name, int tri_state) {
- if (tri_state == 1) {
- curl_mimepart *part = curl_mime_addpart(multipart);
- curl_mime_name(part, name);
- curl_mime_data(part, "true", CURL_ZERO_TERMINATED);
+ const char *text;
+ curl_mimepart *part;
+ if (tri_state < 0) {
+ return;
}
+ text = tri_state ? "true" : "false";
+ zb_note_http_field(name, text);
+ part = curl_mime_addpart(multipart);
+ curl_mime_name(part, name);
+ curl_mime_data(part, text, CURL_ZERO_TERMINATED);
}
static void p3_bulk_root_multipart(
@@ -237,7 +250,20 @@ static void p3_bulk_root_multipart(
}
long http_code = 0;
- zero_bounce_record_request_url_for_test(url_path);
+ zb_note_http_request("POST", url_path, NULL);
+ zb_note_http_field("api_key", zb->api_key ? zb->api_key : "");
+ zb_note_http_field("file", "");
+ if (add_fields) {
+ CURL *note_curl = curl_easy_init();
+ if (note_curl) {
+ curl_mime *note_mime = curl_mime_init(note_curl);
+ if (note_mime) {
+ add_fields(note_mime, fields_ctx);
+ curl_mime_free(note_mime);
+ }
+ curl_easy_cleanup(note_curl);
+ }
+ }
if (zb_http_perform_if_mock(&response_data, &http_code, NULL)) {
p3_send_file_json_success(&response_data, http_code, success_callback, error_callback);
goto cleanup;
@@ -336,7 +362,7 @@ static void p3_bulk_root_get_file(
long http_code = 0;
char *content_type = NULL;
- zero_bounce_record_request_url_for_test(url_path);
+ zb_note_http_request("GET", url_path, NULL);
if (!zb_http_perform_if_mock(&response_data, &http_code, &content_type)) {
CURL *curl = curl_easy_init();
if (!curl) {
@@ -366,14 +392,10 @@ static void p3_bulk_root_get_file(
curl_slist_free_all(headers);
}
- if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
- } else if (zb_get_file_json_indicates_error(response_data.response)) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ const char *body = response_data.response ? response_data.response : "";
+ if (http_code > 299 || zb_body_looks_like_html(body) || body[0] == '\0' ||
+ zb_get_file_json_indicates_error(body)) {
+ zb_report_body_error(body, http_code, error_callback);
} else if (success_callback) {
FILE *file_stream = fopen(local_download_path, "wb");
if (file_stream == NULL) {
@@ -422,17 +444,9 @@ static void p3_bulk_root_file_status(
free(url_path);
return;
}
- if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
- } else if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- if (error_callback) {
- error_callback(parse_error("Failed to parse json string"));
- }
- } else {
+ {
+ json_object *j_obj = NULL;
+ if (zb_read_success_json(response_data.response, http_code, &j_obj, error_callback) && success_callback) {
success_callback(zb_file_status_response_from_json(j_obj));
json_object_put(j_obj);
}
@@ -467,17 +481,9 @@ static void p3_bulk_root_delete_file(
free(url_path);
return;
}
- if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
- } else if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- if (error_callback) {
- error_callback(parse_error("Failed to parse json string"));
- }
- } else {
+ {
+ json_object *j_obj = NULL;
+ if (zb_read_success_json(response_data.response, http_code, &j_obj, error_callback) && success_callback) {
success_callback(zb_delete_file_response_from_json(j_obj));
json_object_put(j_obj);
}
@@ -775,7 +781,7 @@ static char *p3_build_validate_s3_body(ZeroBounce *zb, const ValidateS3FileOptio
p3_append_optional_bool_form(&body, &cap, "has_header_row", options->has_header_row);
p3_append_optional_bool_form(&body, &cap, "remove_duplicates", options->remove_duplicates);
p3_append_optional_bool_form(&body, &cap, "remove_duplicate", options->remove_duplicate);
- if (options->email_address_column > 0) {
+ if (options->email_address_column >= 0) {
char col[16];
snprintf(col, sizeof(col), "%d", options->email_address_column);
p3_append_form_field(&body, &cap, "email_address_column", col);
@@ -816,6 +822,9 @@ void validate_s3_file(
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
if (!options || !options->file_name || !options->file_name[0]) {
if (error_callback) {
error_callback(parse_error("Empty parameter: file_name"));
@@ -834,6 +843,9 @@ void score_s3_file(
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
if (!options || !options->file_name || !options->file_name[0]) {
if (error_callback) {
error_callback(parse_error("Empty parameter: file_name"));
@@ -847,7 +859,7 @@ void score_s3_file(
p3_append_optional_bool_form(&body, &cap, "has_header_row", options->has_header_row);
p3_append_optional_bool_form(&body, &cap, "remove_duplicates", options->remove_duplicates);
p3_append_optional_bool_form(&body, &cap, "remove_duplicate", options->remove_duplicate);
- if (options->email_address_column > 0) {
+ if (options->email_address_column >= 0) {
char col[16];
snprintf(col, sizeof(col), "%d", options->email_address_column);
p3_append_form_field(&body, &cap, "email_address_column", col);
@@ -926,6 +938,9 @@ void export_s3_file(
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
if (!file_id || !file_id[0]) {
if (error_callback) {
error_callback(parse_error("Empty parameter: file_id"));
diff --git a/src/ZBErrorResponse.c b/src/ZBErrorResponse.c
index f76379d..9b52cbe 100644
--- a/src/ZBErrorResponse.c
+++ b/src/ZBErrorResponse.c
@@ -1,7 +1,9 @@
+#include
#include
+#include
#include
-#include "ZeroBounce/ZBErrorResponse.h"
+#include "ZeroBounce/zb_internal.h"
ZBErrorResponse new_zb_error_response() {
ZBErrorResponse response;
@@ -89,6 +91,250 @@ ZBErrorResponse parse_error(const char* error) {
return response;
}
+static const char *zb_skip_ws(const char *value) {
+ if (!value) {
+ return "";
+ }
+ while (*value && isspace((unsigned char)*value)) {
+ value++;
+ }
+ return value;
+}
+
+static char *zb_dup(const char *value) {
+ char *copy = strdup(value ? value : "");
+ return copy ? copy : strdup("");
+}
+
+static int zb_starts_with(const char *value, const char *prefix) {
+ size_t n = strlen(prefix);
+ return strncmp(value, prefix, n) == 0;
+}
+
+static char *zb_strip_error_prefix(const char *value) {
+ char *message = zb_dup(zb_skip_ws(value));
+ if (!message) {
+ return strdup("");
+ }
+ for (;;) {
+ char *start = message;
+ while (*start && isspace((unsigned char)*start)) {
+ start++;
+ }
+ if (start != message) {
+ memmove(message, start, strlen(start) + 1);
+ }
+ if (zb_starts_with(message, "Error:")) {
+ memmove(message, message + 6, strlen(message + 6) + 1);
+ continue;
+ }
+ if (zb_starts_with(message, "[Error]:")) {
+ memmove(message, message + 8, strlen(message + 8) + 1);
+ continue;
+ }
+ return message;
+ }
+}
+
+static void zb_lower_prefix(const char *value, char *out, size_t out_len) {
+ size_t i = 0;
+ if (!value) {
+ out[0] = '\0';
+ return;
+ }
+ for (; value[i] && i + 1 < out_len && i < 2000; i++) {
+ out[i] = (char)tolower((unsigned char)value[i]);
+ }
+ out[i] = '\0';
+}
+
+bool zb_body_looks_like_html(const char *body) {
+ char lower[2001];
+ zb_lower_prefix(body, lower, sizeof(lower));
+ return strstr(lower, "');
+ if (!open_end) {
+ return NULL;
+ }
+ close = strstr(open_end, "");
+ if (!close) {
+ return NULL;
+ }
+ len = (size_t)(close - open_end - 1);
+ title = (char *)malloc(len + 1);
+ if (!title) {
+ return NULL;
+ }
+ memcpy(title, body + (open_end - lower) + 1, len);
+ title[len] = '\0';
+ return title;
+}
+
+static bool zb_json_key_has_error_or_message(json_object *object) {
+ json_object_object_foreach(object, key, val) {
+ (void)val;
+ if (strstr(key, "error") || strstr(key, "message")) {
+ return true;
+ }
+ }
+ return false;
+}
+
+char *zb_message_from_body(const char *body, long status_code) {
+ const char *trimmed = zb_skip_ws(body);
+ char status_buf[16];
+ if (!trimmed[0]) {
+ snprintf(status_buf, sizeof(status_buf), "%ld", status_code);
+ char *empty = (char *)malloc(80);
+ if (!empty) {
+ return strdup("Empty response from ZeroBounce");
+ }
+ snprintf(empty, 80, "Empty response from ZeroBounce (HTTP %s)", status_buf);
+ return empty;
+ }
+ if (zb_body_looks_like_html(body)) {
+ char *title = zb_html_title(body);
+ char *message;
+ if (!title || !title[0]) {
+ free(title);
+ return strdup("ZeroBounce returned an HTML error page instead of an API response.");
+ }
+ message = (char *)malloc(strlen(title) + 96);
+ if (!message) {
+ free(title);
+ return strdup("ZeroBounce returned an HTML error page instead of an API response.");
+ }
+ snprintf(
+ message,
+ strlen(title) + 96,
+ "ZeroBounce returned an HTML error page (\"%s\") instead of an API response.",
+ title
+ );
+ free(title);
+ return message;
+ }
+ if (trimmed[0] == '{' || trimmed[0] == '[') {
+ json_object *parsed = json_tokener_parse(trimmed);
+ if (!parsed) {
+ return strdup("ZeroBounce returned a response that is not valid JSON.");
+ }
+ if (json_object_is_type(parsed, json_type_object)) {
+ json_object *title = json_object_object_get(parsed, "title");
+ if (title && json_object_is_type(title, json_type_string)) {
+ const char *title_text = json_object_get_string(title);
+ json_object *cf = json_object_object_get(parsed, "cloudflare_error");
+ bool access_denied = title_text &&
+ (strstr(title_text, "Access denied") || strstr(title_text, "1020"));
+ bool cloudflare = cf && json_object_is_type(cf, json_type_boolean) && json_object_get_boolean(cf);
+ if (access_denied && (cloudflare || !zb_json_key_has_error_or_message(parsed))) {
+ char *copy = zb_dup(title_text);
+ json_object_put(parsed);
+ return copy;
+ }
+ }
+ }
+ json_object_put(parsed);
+ return zb_dup(trimmed);
+ }
+ if (strlen(trimmed) > 1000) {
+ return strdup("ZeroBounce returned a response that is not valid JSON.");
+ }
+ {
+ char *message = zb_strip_error_prefix(trimmed);
+ if (!message || !message[0]) {
+ free(message);
+ snprintf(status_buf, sizeof(status_buf), "%ld", status_code);
+ message = (char *)malloc(80);
+ if (!message) {
+ return strdup("Empty response from ZeroBounce");
+ }
+ snprintf(message, 80, "Empty response from ZeroBounce (HTTP %s)", status_buf);
+ }
+ return message;
+ }
+}
+
+void zb_report_body_error(const char *body, long status_code, OnErrorCallback error_callback) {
+ char *message;
+ if (!error_callback) {
+ return;
+ }
+ message = zb_message_from_body(body, status_code);
+ error_callback(parse_error(message));
+ free(message);
+}
+
+bool zb_read_success_json(const char *body, long status_code, json_object **out, OnErrorCallback error_callback) {
+ const char *text = body ? body : "";
+ json_object *parsed;
+ if (!out) {
+ return false;
+ }
+ *out = NULL;
+ if (status_code > 299 || zb_body_looks_like_html(text) || zb_skip_ws(text)[0] == '\0') {
+ zb_report_body_error(text, status_code, error_callback);
+ return false;
+ }
+ parsed = json_tokener_parse(text);
+ if (!parsed) {
+ zb_report_body_error(text, status_code, error_callback);
+ return false;
+ }
+ *out = parsed;
+ return true;
+}
+
+bool zb_batch_has_account_error(const json_object *batch, char **message_out) {
+ json_object *errors = NULL;
+ size_t i;
+ size_t count;
+ if (message_out) {
+ *message_out = NULL;
+ }
+ if (!batch || !json_object_object_get_ex(batch, "errors", &errors) ||
+ !json_object_is_type(errors, json_type_array)) {
+ return false;
+ }
+ count = json_object_array_length(errors);
+ for (i = 0; i < count; i++) {
+ json_object *item = json_object_array_get_idx(errors, i);
+ json_object *address = item ? json_object_object_get(item, "email_address") : NULL;
+ const char *address_text = address && json_object_is_type(address, json_type_string)
+ ? json_object_get_string(address) : NULL;
+ if (address_text && strcmp(address_text, "all") == 0) {
+ json_object *error = json_object_object_get(item, "error");
+ const char *error_text = error && json_object_is_type(error, json_type_string)
+ ? json_object_get_string(error) : NULL;
+ if (message_out) {
+ *message_out = zb_dup(
+ error_text && error_text[0]
+ ? error_text
+ : "Invalid API Key or your account ran out of credits"
+ );
+ }
+ return true;
+ }
+ }
+ return false;
+}
+
int compare_zb_error_response(const ZBErrorResponse* response1, const ZBErrorResponse* response2) {
if (response1->success != response2->success) {
return 0;
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index 9068dbf..d5ff911 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -53,6 +53,67 @@ void zero_bounce_record_request_url_for_test(const char* url) {
s_last_request_url_for_test[sizeof(s_last_request_url_for_test) - 1] = '\0';
}
+#define ZB_TEST_FIELD_MAX 48
+
+typedef struct {
+ char method[16];
+ char url[8192];
+ char body[32768];
+ struct {
+ char name[64];
+ char value[1024];
+ } fields[ZB_TEST_FIELD_MAX];
+ int field_count;
+} ZBTlsRequest;
+
+static _Thread_local ZBTlsRequest zb_tls_request;
+
+static void zb_copy_cap(char *dest, size_t dest_len, const char *src) {
+ if (!dest || dest_len == 0) {
+ return;
+ }
+ if (!src) {
+ dest[0] = '\0';
+ return;
+ }
+ snprintf(dest, dest_len, "%s", src);
+}
+
+void zb_note_http_request(const char *method, const char *url, const char *body) {
+ memset(&zb_tls_request, 0, sizeof(zb_tls_request));
+ zb_copy_cap(zb_tls_request.method, sizeof(zb_tls_request.method), method ? method : "");
+ zb_copy_cap(zb_tls_request.url, sizeof(zb_tls_request.url), url ? url : "");
+ zb_copy_cap(zb_tls_request.body, sizeof(zb_tls_request.body), body ? body : "");
+ zero_bounce_record_request_url_for_test(url);
+ if (body) {
+ strncpy(s_last_post_body_for_test, body, sizeof(s_last_post_body_for_test) - 1);
+ s_last_post_body_for_test[sizeof(s_last_post_body_for_test) - 1] = '\0';
+ }
+}
+
+void zb_note_http_field(const char *name, const char *value) {
+ int index;
+ if (zb_tls_request.field_count >= ZB_TEST_FIELD_MAX) {
+ return;
+ }
+ index = zb_tls_request.field_count++;
+ zb_copy_cap(zb_tls_request.fields[index].name, sizeof(zb_tls_request.fields[index].name), name);
+ zb_copy_cap(zb_tls_request.fields[index].value, sizeof(zb_tls_request.fields[index].value), value);
+}
+
+const char *zero_bounce_test_method(void) { return zb_tls_request.method; }
+const char *zero_bounce_test_url(void) { return zb_tls_request.url; }
+const char *zero_bounce_test_body(void) { return zb_tls_request.body; }
+int zero_bounce_test_field_count(void) { return zb_tls_request.field_count; }
+const char *zero_bounce_test_field_name(int index) {
+ if (index < 0 || index >= zb_tls_request.field_count) return "";
+ return zb_tls_request.fields[index].name;
+}
+const char *zero_bounce_test_field_value(int index) {
+ if (index < 0 || index >= zb_tls_request.field_count) return "";
+ return zb_tls_request.fields[index].value;
+}
+
char* zb_url_escape(const char* value) {
char* escaped = curl_easy_escape(NULL, value ? value : "", 0);
char* copy = escaped ? strdup(escaped) : NULL;
@@ -464,9 +525,7 @@ int zb_make_request_with_post_body(
long* http_code,
OnErrorCallback error_callback
) {
- strncpy(s_last_post_body_for_test, body ? body : "", sizeof(s_last_post_body_for_test) - 1);
- s_last_post_body_for_test[sizeof(s_last_post_body_for_test) - 1] = '\0';
- zero_bounce_record_request_url_for_test(url_path);
+ zb_note_http_request("POST", url_path, body ? body : "");
if (s_http_perform) {
return s_http_perform(response_data, http_code, NULL);
@@ -522,7 +581,7 @@ int zb_make_request(
long* http_code,
OnErrorCallback error_callback
) {
- zero_bounce_record_request_url_for_test(url_path);
+ zb_note_http_request(request_type ? request_type : "GET", url_path, NULL);
if (s_http_perform) {
return s_http_perform(response_data, http_code, NULL);
}
@@ -567,6 +626,45 @@ int zb_make_request(
return 1;
}
+static void note_send_file_fields(
+ ZeroBounce *zb,
+ bool scoring,
+ int email_address_column_index,
+ SendFileOptions options
+) {
+ char int_str_option[16];
+ zb_note_http_field("api_key", zb->api_key ? zb->api_key : "");
+ zb_note_http_field("file", "");
+ snprintf(int_str_option, sizeof(int_str_option), "%d", email_address_column_index);
+ zb_note_http_field("email_address_column", int_str_option);
+ if (!scoring) {
+ if (options.first_name_column > 0) {
+ snprintf(int_str_option, sizeof(int_str_option), "%d", options.first_name_column);
+ zb_note_http_field("first_name_column", int_str_option);
+ }
+ if (options.last_name_column > 0) {
+ snprintf(int_str_option, sizeof(int_str_option), "%d", options.last_name_column);
+ zb_note_http_field("last_name_column", int_str_option);
+ }
+ if (options.gender_column > 0) {
+ snprintf(int_str_option, sizeof(int_str_option), "%d", options.gender_column);
+ zb_note_http_field("gender_column", int_str_option);
+ }
+ if (options.ip_address_column > 0) {
+ snprintf(int_str_option, sizeof(int_str_option), "%d", options.ip_address_column);
+ zb_note_http_field("ip_address_column", int_str_option);
+ }
+ }
+ if (options.return_url && strlen(options.return_url) != 0) {
+ zb_note_http_field("return_url", options.return_url);
+ }
+ zb_note_http_field("has_header_row", options.has_header_row ? "true" : "false");
+ zb_note_http_field("remove_duplicate", options.remove_duplicate ? "true" : "false");
+ if (!scoring && options.allow_phase_2_is_set) {
+ zb_note_http_field("allow_phase_2", options.allow_phase_2 ? "true" : "false");
+ }
+}
+
static void send_file_internal(
ZeroBounce *zb,
bool scoring,
@@ -597,6 +695,9 @@ static void send_file_internal(
memory response_data = {0};
+ zb_note_http_request("POST", url_path, NULL);
+ note_send_file_fields(zb, scoring, email_address_column_index, options);
+
if (s_http_perform) {
long http_code = 0;
if (!s_http_perform(&response_data, &http_code, NULL)) {
@@ -604,11 +705,11 @@ static void send_file_internal(
goto cleanup;
}
if (http_code > 299) {
- if (error_callback) error_callback(parse_error(response_data.response));
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
+ goto cleanup;
} else {
success_callback(zb_send_file_response_from_json(j_obj));
json_object_put(j_obj);
@@ -724,14 +825,11 @@ static void send_file_internal(
curl_mime_free(multipart);
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
@@ -786,14 +884,11 @@ static void file_status_internal(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
@@ -837,6 +932,8 @@ static void get_file_internal(
long http_code = 0;
char* content_type = NULL;
+ zb_note_http_request("GET", url_path, NULL);
+
if (s_http_perform) {
if (!s_http_perform(&response_data, &http_code, &content_type)) {
if (error_callback) error_callback(parse_error("Mock request failed"));
@@ -891,15 +988,8 @@ static void get_file_internal(
const char *body = response_data.response ? response_data.response : "";
- if (http_code > 299) {
- if (error_callback) {
- char *fmt = NULL;
- if (body[0] == '{') {
- fmt = zb_format_get_file_error_message(body);
- }
- error_callback(parse_error(fmt && fmt[0] ? fmt : body));
- free(fmt);
- }
+ if (http_code > 299 || zb_body_looks_like_html(body) || body[0] == '\0') {
+ zb_report_body_error(body, http_code, error_callback);
} else if (success_callback) {
if (zb_should_treat_getfile_body_as_error(body, content_type)) {
if (error_callback) {
@@ -994,14 +1084,11 @@ static void delete_file_internal(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
@@ -1052,14 +1139,11 @@ static void find_email_internal(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
ZBFindEmailResponse response_obj = zb_find_email_response_from_json(j_obj);
@@ -1106,14 +1190,11 @@ static void search_domain_internal(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
ZBDomainSearchResponse response_obj = zb_domain_search_response_from_json(j_obj);
@@ -1164,14 +1245,11 @@ void get_credits(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
@@ -1235,14 +1313,11 @@ void get_api_usage(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
@@ -1374,13 +1449,11 @@ void validate_email_with_options(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
+ /* reported */
} else {
success_callback(zb_validate_response_from_json(j_obj));
json_object_put(j_obj);
@@ -1468,14 +1541,11 @@ void validate_email_with_options(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
@@ -1538,8 +1608,12 @@ void validate_email_batch_with_options(
for(size_t i = 0; i < email_batch.size; i++) {
json_object *email_obj = json_object_new_object();
- json_object_object_add(email_obj, "email_address", json_object_new_string(email_batch.data[i].email_address));
- json_object_object_add(email_obj, "ip_address", json_object_new_string(email_batch.data[i].ip_address));
+ json_object_object_add(email_obj, "email_address", json_object_new_string(email_batch.data[i].email_address ? email_batch.data[i].email_address : ""));
+ if (!email_batch.data[i].ip_address || !email_batch.data[i].ip_address[0]) {
+ json_object_object_add(email_obj, "ip_address", json_object_new_null());
+ } else {
+ json_object_object_add(email_obj, "ip_address", json_object_new_string(email_batch.data[i].ip_address));
+ }
json_object_array_add(email_batch_json, email_obj);
}
@@ -1564,8 +1638,7 @@ void validate_email_batch_with_options(
}
const char* payload_str = json_object_to_json_string(payload);
- strncpy(s_last_post_body_for_test, payload_str, sizeof(s_last_post_body_for_test) - 1);
- s_last_post_body_for_test[sizeof(s_last_post_body_for_test) - 1] = '\0';
+ zb_note_http_request("POST", url_path, payload_str);
memory response_data = {0};
long http_code = 0;
@@ -1623,18 +1696,23 @@ void validate_email_batch_with_options(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
- success_callback(zb_validate_batch_response_from_json(j_obj));
+ char *account_error = NULL;
+ if (zb_batch_has_account_error(j_obj, &account_error)) {
+ if (error_callback) {
+ error_callback(parse_error(account_error));
+ }
+ free(account_error);
+ } else {
+ success_callback(zb_validate_batch_response_from_json(j_obj));
+ }
json_object_put(j_obj);
}
}
@@ -1789,14 +1867,11 @@ void get_activity_data(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
@@ -1872,13 +1947,10 @@ static void score_email_internal(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
@@ -2039,14 +2111,11 @@ void find_email(
}
if (http_code > 299) {
- if (error_callback) {
- error_callback(parse_error(response_data.response));
- }
+ zb_report_body_error(response_data.response, http_code, error_callback);
} else {
if (success_callback) {
- json_object *j_obj = json_tokener_parse(response_data.response);
- if (j_obj == NULL) {
- error_callback(parse_error("Failed to parse json string"));
+ json_object *j_obj = NULL;
+ if (!zb_read_success_json(response_data.response, http_code, &j_obj, error_callback)) {
goto cleanup;
}
ZBFindEmailResponse response_obj = zb_find_email_response_from_json(j_obj);
diff --git a/test/OfflineApiTest.c b/test/OfflineApiTest.c
new file mode 100644
index 0000000..5ad9259
--- /dev/null
+++ b/test/OfflineApiTest.c
@@ -0,0 +1,909 @@
+#include
+#include
+#include
+#include
+#include
+
+#include "unity.h"
+#include "ZeroBounce/ZeroBounce.h"
+
+#define OFFLINE_MAX 64
+
+typedef struct {
+ char method[16];
+ char host[128];
+ char path[256];
+ char api_key[256];
+ char body[8192];
+ char query[4096];
+} OfflineRequest;
+
+static OfflineRequest g_requests[OFFLINE_MAX];
+static int g_count;
+static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
+static char g_error_text[1024];
+static int g_error_called;
+static int g_success_called;
+static char g_echo[256];
+static _Thread_local char tls_echo[256];
+
+static const char *html_page(void) {
+ static char *page = NULL;
+ if (!page) {
+ size_t i;
+ page = malloc(256 + 20000 * 16);
+ strcpy(page, "Access denied | zerobounce.net | Zero Bounce");
+ for (i = 0; i < 20000; i++) {
+ strcat(page, "blocked
");
+ }
+ strcat(page, "");
+ }
+ return page;
+}
+
+static void url_decode(const char *in, char *out, size_t out_len) {
+ size_t o = 0;
+ for (size_t i = 0; in[i] && o + 1 < out_len; i++) {
+ if (in[i] == '%' && in[i + 1] && in[i + 2]) {
+ char hex[3] = {in[i + 1], in[i + 2], 0};
+ out[o++] = (char)strtol(hex, NULL, 16);
+ i += 2;
+ } else if (in[i] == '+') {
+ out[o++] = ' ';
+ } else {
+ out[o++] = in[i];
+ }
+ }
+ out[o] = '\0';
+}
+
+static void split_url(const char *url, char *host, char *path, char *query) {
+ const char *start = strstr(url, "://");
+ const char *host_start = start ? start + 3 : url;
+ const char *path_start = strchr(host_start, '/');
+ const char *query_start = strchr(host_start, '?');
+ host[0] = path[0] = query[0] = '\0';
+ if (!path_start) {
+ snprintf(host, 128, "%s", host_start);
+ snprintf(path, 256, "/");
+ return;
+ }
+ snprintf(host, 128, "%.*s", (int)(path_start - host_start), host_start);
+ if (!query_start) {
+ snprintf(path, 256, "%s", path_start);
+ return;
+ }
+ snprintf(path, 256, "%.*s", (int)(query_start - path_start), path_start);
+ snprintf(query, 4096, "%s", query_start + 1);
+}
+
+static int query_value(const char *query, const char *key, char *out, size_t out_len) {
+ size_t key_len = strlen(key);
+ const char *p = query;
+ while (p && *p) {
+ if ((p == query || p[-1] == '&') && strncmp(p, key, key_len) == 0 && p[key_len] == '=') {
+ char raw[1024];
+ const char *end = strchr(p + key_len + 1, '&');
+ size_t n = end ? (size_t)(end - (p + key_len + 1)) : strlen(p + key_len + 1);
+ if (n >= sizeof(raw)) n = sizeof(raw) - 1;
+ memcpy(raw, p + key_len + 1, n);
+ raw[n] = '\0';
+ url_decode(raw, out, out_len);
+ return 1;
+ }
+ p = strchr(p, '&');
+ if (p) p++;
+ }
+ return 0;
+}
+
+static int field_value(const char *name, char *out, size_t out_len) {
+ int count = zero_bounce_test_field_count();
+ for (int i = 0; i < count; i++) {
+ if (strcmp(zero_bounce_test_field_name(i), name) == 0) {
+ snprintf(out, out_len, "%s", zero_bounce_test_field_value(i));
+ return 1;
+ }
+ }
+ return 0;
+}
+
+static void remember(const OfflineRequest *request) {
+ pthread_mutex_lock(&g_mu);
+ if (g_count < OFFLINE_MAX) {
+ g_requests[g_count++] = *request;
+ }
+ pthread_mutex_unlock(&g_mu);
+}
+
+static void set_body(memory *response_data, const char *text) {
+ size_t len = strlen(text);
+ response_data->response = malloc(len + 1);
+ memcpy(response_data->response, text, len + 1);
+ response_data->size = len;
+}
+
+static int offline_perform(memory *response_data, long *http_code, char **content_type_out) {
+ OfflineRequest request;
+ char key[256] = "";
+ const char *url = zero_bounce_test_url();
+ const char *body = zero_bounce_test_body();
+ memset(&request, 0, sizeof(request));
+ snprintf(request.method, sizeof(request.method), "%s", zero_bounce_test_method());
+ split_url(url ? url : "", request.host, request.path, request.query);
+ snprintf(request.body, sizeof(request.body), "%s", body ? body : "");
+ if (!query_value(request.query, "api_key", key, sizeof(key))) {
+ query_value(request.body, "api_key", key, sizeof(key));
+ }
+ if (!key[0]) {
+ const char *marker = strstr(request.body, "\"api_key\"");
+ if (marker) {
+ marker += 9;
+ while (*marker == ' ' || *marker == ':' || *marker == '\t') marker++;
+ if (*marker == '"') {
+ const char *end;
+ marker++;
+ end = strchr(marker, '"');
+ if (end && (size_t)(end - marker) < sizeof(key)) {
+ memcpy(key, marker, (size_t)(end - marker));
+ key[end - marker] = '\0';
+ }
+ }
+ }
+ }
+ if (!key[0]) {
+ field_value("api_key", key, sizeof(key));
+ }
+ snprintf(request.api_key, sizeof(request.api_key), "%s", key);
+ remember(&request);
+
+ usleep((useconds_t)(rand() % 21) * 1000);
+
+ const char *content = "application/json";
+ *http_code = 200;
+ if (strncmp(key, "html-403-", 9) == 0 || strncmp(key, "html-200-", 9) == 0) {
+ *http_code = strncmp(key, "html-403-", 9) == 0 ? 403 : 200;
+ set_body(response_data, html_page());
+ content = "text/html; charset=UTF-8";
+ } else if (strncmp(key, "empty-", 6) == 0) {
+ set_body(response_data, "");
+ } else if (strncmp(key, "malformed-", 10) == 0) {
+ char *junk = malloc(6000);
+ strcpy(junk, "<<>>");
+ memset(junk + 14, 'x', 5000);
+ junk[5014] = '\0';
+ set_body(response_data, junk);
+ free(junk);
+ } else if (strncmp(key, "cf-json-", 8) == 0) {
+ *http_code = 403;
+ set_body(response_data,
+ "{\"type\":\"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1020/\","
+ "\"title\":\"Error 1020: Access denied\",\"status\":403,\"error_code\":1020,\"cloudflare_error\":true}");
+ } else if (strncmp(key, "double-error-", 13) == 0) {
+ *http_code = 400;
+ set_body(response_data, "Error: Error: api_key is invalid");
+ content = "text/plain";
+ } else if (strstr(request.path, "validatebatch") && strncmp(key, "batch-account-", 14) == 0) {
+ set_body(response_data,
+ "{\"email_batch\":[],\"errors\":[{\"email_address\":\"all\",\"error\":\"Invalid API Key or your account ran out of credits\"}]}");
+ } else if (strstr(request.path, "validatebatch") && strncmp(key, "batch-partial-", 14) == 0) {
+ set_body(response_data,
+ "{\"email_batch\":[{\"address\":\"good@example.com\",\"status\":\"valid\"}],\"errors\":[{\"email_address\":\"bad@\",\"error\":\"Invalid email address\"}]}");
+ } else if (strstr(request.path, "getfile")) {
+ char csv[512];
+ snprintf(csv, sizeof(csv), "email\n%s@example.com\n", key);
+ set_body(response_data, csv);
+ content = "application/octet-stream";
+ } else if (strstr(request.path, "listS3files")) {
+ char json[512];
+ snprintf(json, sizeof(json), "[{\"name\":\"%s\"}]", key);
+ set_body(response_data, json);
+ } else if (strstr(request.path, "validatebatch")) {
+ char json[512];
+ snprintf(json, sizeof(json), "{\"email_batch\":[{\"address\":\"%s\",\"status\":\"valid\"}],\"errors\":[]}", key);
+ set_body(response_data, json);
+ } else if (strcmp(request.path + strlen(request.path) - 9, "/validate") == 0 ||
+ (strlen(request.path) >= 9 && strstr(request.path, "/validate") && !strstr(request.path, "validatebatch") && !strstr(request.path, "validateS3"))) {
+ char json[256];
+ snprintf(json, sizeof(json), "{\"address\":\"%s\",\"status\":\"valid\"}", key);
+ set_body(response_data, json);
+ } else if (strstr(request.path, "/scoring") && !strstr(request.path, "sendfile") && !strstr(request.path, "filestatus") && !strstr(request.path, "getfile") && !strstr(request.path, "deletefile")) {
+ char json[256];
+ snprintf(json, sizeof(json), "{\"email\":\"%s\",\"score\":10}", key);
+ set_body(response_data, json);
+ } else if (strstr(request.path, "getcredits")) {
+ set_body(response_data, "{\"Credits\":\"7\"}");
+ } else if (strstr(request.path, "getapiusage")) {
+ set_body(response_data, "{\"total\":1}");
+ } else if (strstr(request.path, "/activity")) {
+ set_body(response_data, "{\"found\":true,\"active_in_days\":\"3\"}");
+ } else if (strstr(request.path, "guessformat")) {
+ char json[256];
+ snprintf(json, sizeof(json), "{\"email\":\"%s\",\"domain\":\"%s\",\"other_domain_formats\":[]}", key, key);
+ set_body(response_data, json);
+ } else if (strstr(request.path, "filters") || strstr(request.path, "listevaluator")) {
+ char json[256];
+ snprintf(json, sizeof(json), "{\"echo_api_key\":\"%s\"}", key);
+ set_body(response_data, json);
+ } else {
+ char json[512];
+ snprintf(json, sizeof(json),
+ "{\"success\":true,\"message\":\"ok\",\"file_id\":\"%s\",\"file_name\":\"f.csv\",\"email\":\"%s\",\"domain\":\"%s\",\"other_domain_formats\":[]}",
+ key, key, key);
+ set_body(response_data, json);
+ }
+ if (content_type_out) {
+ *content_type_out = strdup(content);
+ }
+ return 1;
+}
+
+static void reset_offline(void) {
+ pthread_mutex_lock(&g_mu);
+ g_count = 0;
+ pthread_mutex_unlock(&g_mu);
+ g_error_called = 0;
+ g_success_called = 0;
+ g_error_text[0] = '\0';
+ g_echo[0] = '\0';
+ zero_bounce_set_http_perform_for_test(offline_perform);
+}
+
+static ZeroBounce *client_with(const char *key) {
+ ZeroBounce *zb = new_zero_bounce_instance();
+ zero_bounce_initialize(zb, key);
+ return zb;
+}
+
+static void on_error(ZBErrorResponse error) {
+ size_t used = 0;
+ g_error_called = 1;
+ g_error_text[0] = '\0';
+ for (size_t i = 0; i < error.errors.size; i++) {
+ const char *part = error.errors.data[i] ? error.errors.data[i] : "";
+ size_t n = strlen(part);
+ if (used && used + 1 < sizeof(g_error_text)) {
+ g_error_text[used++] = ' ';
+ }
+ if (used + n >= sizeof(g_error_text)) {
+ n = sizeof(g_error_text) - used - 1;
+ }
+ memcpy(g_error_text + used, part, n);
+ used += n;
+ g_error_text[used] = '\0';
+ }
+}
+
+static void expect_short_error(void) {
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_TRUE(strlen(g_error_text) < 500);
+ TEST_ASSERT_NULL(strstr(g_error_text, "blocked
"));
+ TEST_ASSERT_NULL(strstr(g_error_text, "Error: Error:"));
+ TEST_ASSERT_NULL(strstr(g_error_text, "[json.exception"));
+}
+
+static OfflineRequest only_request(void) {
+ OfflineRequest empty;
+ memset(&empty, 0, sizeof(empty));
+ TEST_ASSERT_EQUAL_INT(1, g_count);
+ if (g_count == 0) return empty;
+ return g_requests[0];
+}
+
+static int has_field(const char *name, const char *value) {
+ for (int i = 0; i < zero_bounce_test_field_count(); i++) {
+ if (strcmp(zero_bounce_test_field_name(i), name) == 0) {
+ if (!value || strcmp(zero_bounce_test_field_value(i), value) == 0) return 1;
+ }
+ }
+ return 0;
+}
+
+static void on_credits(ZBCreditsResponse response) {
+ g_success_called = 1;
+ TEST_ASSERT_EQUAL_INT(7, response.credits);
+}
+
+static void on_validate(ZBValidateResponse response) {
+ g_success_called = 1;
+ snprintf(tls_echo, sizeof(tls_echo), "%s", response.address ? response.address : "");
+ snprintf(g_echo, sizeof(g_echo), "%s", tls_echo);
+}
+
+static void on_batch(ZBValidateBatchResponse response) {
+ g_success_called = 1;
+ if (response.email_batch.size > 0) {
+ snprintf(g_echo, sizeof(g_echo), "%s", response.email_batch.data[0].address);
+ }
+ g_requests[0].body[0] = g_requests[0].body[0];
+ if (response.errors.size > 0) {
+ snprintf(g_error_text, sizeof(g_error_text), "%s", response.errors.data[0].email_address);
+ }
+}
+
+static void on_send(ZBSendFileResponse response) {
+ g_success_called = 1;
+ snprintf(g_echo, sizeof(g_echo), "%s", response.file_id ? response.file_id : "");
+}
+
+static void on_status(ZBFileStatusResponse response) {
+ g_success_called = 1;
+ snprintf(g_echo, sizeof(g_echo), "%s", response.file_id ? response.file_id : "");
+}
+
+static void on_get(ZBGetFileResponse response) {
+ g_success_called = 1;
+ (void)response;
+}
+
+static void on_delete(ZBDeleteFileResponse response) {
+ g_success_called = 1;
+ (void)response;
+}
+
+static void on_activity(ZBActivityDataResponse response) {
+ g_success_called = 1;
+ TEST_ASSERT_TRUE(response.found);
+}
+
+static void on_score(ZBScoreEmailResponse response) {
+ g_success_called = 1;
+ snprintf(g_echo, sizeof(g_echo), "%s", response.email ? response.email : "");
+}
+
+static void on_find(ZBFindEmailResponse response) {
+ g_success_called = 1;
+ snprintf(g_echo, sizeof(g_echo), "%s", response.email ? response.email : "");
+}
+
+static void on_domain(ZBDomainSearchResponse response) {
+ g_success_called = 1;
+ snprintf(g_echo, sizeof(g_echo), "%s", response.domain ? response.domain : "");
+}
+
+static void on_usage(ZBGetApiUsageResponse response) {
+ g_success_called = 1;
+ TEST_ASSERT_EQUAL_INT(1, response.total);
+}
+
+static char g_raw[1024];
+static void on_raw(const char *json) {
+ g_success_called = 1;
+ snprintf(g_raw, sizeof(g_raw), "%s", json ? json : "");
+}
+
+void test_offline_get_credits(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("credits-key");
+ get_credits(zb, on_credits, on_error);
+ TEST_ASSERT_TRUE(g_success_called);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("GET", request.method);
+ TEST_ASSERT_EQUAL_STRING("api.zerobounce.net", request.host);
+ TEST_ASSERT_EQUAL_STRING("/v2/getcredits", request.path);
+ TEST_ASSERT_EQUAL_STRING("credits-key", request.api_key);
+}
+
+void test_offline_validate_options(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("validate-key");
+ ZBValidateOptions options = new_validate_options();
+ options.activity_data = 0;
+ options.verify_plus = 1;
+ validate_email_with_options(zb, "person@example.com", "", &options, on_validate, on_error);
+ TEST_ASSERT_EQUAL_STRING("validate-key", g_echo);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("GET", request.method);
+ TEST_ASSERT_EQUAL_STRING("/v2/validate", request.path);
+ char value[64];
+ TEST_ASSERT_TRUE(query_value(request.query, "email", value, sizeof(value)));
+ TEST_ASSERT_EQUAL_STRING("person@example.com", value);
+ TEST_ASSERT_TRUE(query_value(request.query, "activity_data", value, sizeof(value)));
+ TEST_ASSERT_EQUAL_STRING("false", value);
+ TEST_ASSERT_TRUE(query_value(request.query, "verify_plus", value, sizeof(value)));
+ TEST_ASSERT_EQUAL_STRING("true", value);
+ TEST_ASSERT_FALSE(query_value(request.query, "timeout", value, sizeof(value)));
+}
+
+void test_offline_validate_post(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("post-key");
+ ZBValidateOptions options = new_validate_options();
+ options.use_post = 1;
+ options.timeout = 15;
+ validate_email_with_options(zb, "person@example.com", "203.0.113.5", &options, on_validate, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("POST", request.method);
+ TEST_ASSERT_EQUAL_STRING("/v2/validate", request.path);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "api_key=post-key"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "email=person%40example.com"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "timeout=15"));
+ TEST_ASSERT_NULL(strstr(request.body, "activity_data"));
+}
+
+void test_offline_batch_omits_and_keeps_zero_ip(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("batch-key");
+ EmailToValidateVector emails = email_to_validate_vector_init();
+ ZBEmailToValidate a = {"a@example.com", ""};
+ ZBEmailToValidate b = {"b@example.com", "203.0.113.9"};
+ email_to_validate_vector_append(&emails, a);
+ email_to_validate_vector_append(&emails, b);
+ validate_email_batch(zb, emails, on_batch, on_error);
+ TEST_ASSERT_EQUAL_STRING("batch-key", g_echo);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("POST", request.method);
+ TEST_ASSERT_EQUAL_STRING("api.zerobounce.net", request.host);
+ TEST_ASSERT_EQUAL_STRING("/v2/validatebatch", request.path);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "null"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "203.0.113.9"));
+ TEST_ASSERT_NULL(strstr(request.body, "\"timeout\""));
+ email_to_validate_vector_free(&emails);
+}
+
+void test_offline_batch_explicit_false(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("batch-opt");
+ EmailToValidateVector emails = email_to_validate_vector_init();
+ ZBEmailToValidate a = {"a@example.com", "0.0.0.0"};
+ email_to_validate_vector_append(&emails, a);
+ ZBValidateOptions options = new_validate_options();
+ options.timeout = 30;
+ options.activity_data = 0;
+ options.verify_plus = 1;
+ validate_email_batch_with_options(zb, emails, &options, on_batch, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "\"timeout\""));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "30"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "\"activity_data\""));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "false"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "\"verify_plus\""));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "true"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "0.0.0.0"));
+ email_to_validate_vector_free(&emails);
+}
+
+void test_offline_send_file_bools_and_zero_column(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("file-key");
+ SendFileOptions options = new_send_file_options();
+ options.return_url = "https://example.com/hook";
+ options.allow_phase_2_is_set = true;
+ options.allow_phase_2 = false;
+ options.remove_duplicate = false;
+ options.has_header_row = true;
+ send_file(zb, "email_file.csv", 0, options, on_send, on_error);
+ TEST_ASSERT_EQUAL_STRING("file-key", g_echo);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("POST", request.method);
+ TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host);
+ TEST_ASSERT_EQUAL_STRING("/v2/sendfile", request.path);
+ TEST_ASSERT_TRUE(has_field("email_address_column", "0"));
+ TEST_ASSERT_TRUE(has_field("file", ""));
+ TEST_ASSERT_TRUE(has_field("has_header_row", "true"));
+ TEST_ASSERT_TRUE(has_field("remove_duplicate", "false"));
+ TEST_ASSERT_TRUE(has_field("allow_phase_2", "false"));
+ TEST_ASSERT_FALSE(has_field("first_name_column", NULL));
+}
+
+void test_offline_scoring_send_omits_validation_fields(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("score-file");
+ SendFileOptions options = new_send_file_options();
+ options.first_name_column = 2;
+ options.allow_phase_2_is_set = true;
+ options.allow_phase_2 = true;
+ scoring_send_file(zb, "email_file.csv", 1, options, on_send, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/v2/scoring/sendfile", request.path);
+ TEST_ASSERT_FALSE(has_field("first_name_column", NULL));
+ TEST_ASSERT_FALSE(has_field("allow_phase_2", NULL));
+ TEST_ASSERT_TRUE(has_field("has_header_row", "true"));
+}
+
+void test_offline_file_status_and_delete(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("status-key");
+ file_status(zb, "file-1", on_status, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/v2/filestatus", request.path);
+ TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host);
+ char value[64];
+ TEST_ASSERT_TRUE(query_value(request.query, "file_id", value, sizeof(value)));
+ TEST_ASSERT_EQUAL_STRING("file-1", value);
+ reset_offline();
+ scoring_file_status(zb, "file-2", on_status, on_error);
+ TEST_ASSERT_EQUAL_STRING("/v2/scoring/filestatus", only_request().path);
+ reset_offline();
+ delete_file(zb, "file-1", on_delete, on_error);
+ TEST_ASSERT_EQUAL_STRING("/v2/deletefile", only_request().path);
+ reset_offline();
+ scoring_delete_file(zb, "file-2", on_delete, on_error);
+ TEST_ASSERT_EQUAL_STRING("/v2/scoring/deletefile", only_request().path);
+}
+
+void test_offline_get_file_options(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("getfile-key");
+ GetFileOptions options = new_get_file_options();
+ options.download_type = ZB_DOWNLOAD_TYPE_PHASE_2;
+ options.activity_data = 1;
+ get_file_with_options(zb, "file-9", "test/downloads/offline-get.csv", &options, on_get, on_error);
+ TEST_ASSERT_TRUE(g_success_called);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/v2/getfile", request.path);
+ char value[64];
+ TEST_ASSERT_TRUE(query_value(request.query, "download_type", value, sizeof(value)));
+ TEST_ASSERT_EQUAL_STRING("phase_2", value);
+ TEST_ASSERT_TRUE(query_value(request.query, "activity_data", value, sizeof(value)));
+ TEST_ASSERT_EQUAL_STRING("true", value);
+ reset_offline();
+ GetFileOptions scoring = new_get_file_options();
+ scoring.download_type = ZB_DOWNLOAD_TYPE_PHASE_1;
+ scoring.activity_data = 1;
+ scoring_get_file_with_options(zb, "file-8", "test/downloads/offline-score.csv", &scoring, on_get, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/v2/scoring/getfile", request.path);
+ TEST_ASSERT_TRUE(query_value(request.query, "download_type", value, sizeof(value)));
+ TEST_ASSERT_FALSE(query_value(request.query, "activity_data", value, sizeof(value)));
+}
+
+void test_offline_activity_usage_score(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("usage-key");
+ struct tm start = {0};
+ struct tm end = {0};
+ start.tm_year = 118; start.tm_mon = 0; start.tm_mday = 1;
+ end.tm_year = 118; end.tm_mon = 0; end.tm_mday = 31;
+ get_api_usage(zb, start, end, on_usage, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/v2/getapiusage", request.path);
+ char value[32];
+ TEST_ASSERT_TRUE(query_value(request.query, "start_date", value, sizeof(value)));
+ TEST_ASSERT_EQUAL_STRING("2018-01-01", value);
+ reset_offline();
+ get_activity_data(zb, "person@example.com", on_activity, on_error);
+ TEST_ASSERT_EQUAL_STRING("/v2/activity", only_request().path);
+ reset_offline();
+ score_email(zb, "person@example.com", on_score, on_error);
+ TEST_ASSERT_EQUAL_STRING("usage-key", g_echo);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("GET", request.method);
+ TEST_ASSERT_EQUAL_STRING("/v2/scoring", request.path);
+ reset_offline();
+ score_email_post(zb, "person@example.com", on_score, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("POST", request.method);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "email=person%40example.com"));
+}
+
+void test_offline_finder_and_domain(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("finder-key");
+ find_email_by_domain_first_middle_last_name(zb, "example.com", "Ada", "M", "Lovelace", on_find, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/v2/guessformat", request.path);
+ char value[64];
+ TEST_ASSERT_TRUE(query_value(request.query, "domain", value, sizeof(value)));
+ TEST_ASSERT_EQUAL_STRING("example.com", value);
+ TEST_ASSERT_FALSE(query_value(request.query, "company_name", value, sizeof(value)));
+ reset_offline();
+ find_email_by_company_name_first_name(zb, "ZeroBounce", "Ada", on_find, on_error);
+ request = only_request();
+ TEST_ASSERT_TRUE(query_value(request.query, "company_name", value, sizeof(value)));
+ TEST_ASSERT_EQUAL_STRING("ZeroBounce", value);
+ TEST_ASSERT_FALSE(query_value(request.query, "domain", value, sizeof(value)));
+ TEST_ASSERT_FALSE(query_value(request.query, "last_name", value, sizeof(value)));
+ reset_offline();
+ search_domain_by_domain(zb, "example.com", on_domain, on_error);
+ TEST_ASSERT_FALSE(query_value(only_request().query, "company_name", value, sizeof(value)));
+ reset_offline();
+ search_domain_by_company_name(zb, "ZeroBounce", on_domain, on_error);
+ request = only_request();
+ TEST_ASSERT_TRUE(query_value(request.query, "company_name", value, sizeof(value)));
+ TEST_ASSERT_FALSE(query_value(request.query, "domain", value, sizeof(value)));
+}
+
+void test_offline_priority3(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("p3-key");
+ EmailFinderSendFileOptions finder = new_email_finder_send_file_options();
+ finder.has_header_row = 0;
+ finder.first_name_column = 0;
+ send_email_finder_file(zb, "email_file.csv", 1, &finder, on_send, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host);
+ TEST_ASSERT_EQUAL_STRING("/email-finder/sendfile", request.path);
+ TEST_ASSERT_TRUE(has_field("domain_column", "1"));
+ TEST_ASSERT_TRUE(has_field("has_header_row", "false"));
+ TEST_ASSERT_FALSE(has_field("first_name_column", NULL));
+ reset_offline();
+ email_finder_file_status(zb, "fid", on_status, on_error);
+ TEST_ASSERT_EQUAL_STRING("/email-finder/filestatus", only_request().path);
+ reset_offline();
+ email_finder_get_file(zb, "fid", "test/downloads/finder.csv", on_get, on_error);
+ TEST_ASSERT_EQUAL_STRING("/email-finder/getfile", only_request().path);
+ reset_offline();
+ email_finder_delete_file(zb, "fid", on_delete, on_error);
+ TEST_ASSERT_EQUAL_STRING("/email-finder/deletefile", only_request().path);
+ reset_offline();
+ DomainSearchSendFileOptions domain_opts = new_domain_search_send_file_options();
+ domain_opts.has_header_row = 0;
+ send_domain_search_file(zb, "email_file.csv", 2, &domain_opts, on_send, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/domain-search/sendfile", request.path);
+ TEST_ASSERT_TRUE(has_field("has_header_row", "false"));
+ TEST_ASSERT_TRUE(has_field("domain_column", "2"));
+ reset_offline();
+ domain_search_file_status(zb, "fid", on_status, on_error);
+ TEST_ASSERT_EQUAL_STRING("/domain-search/filestatus", only_request().path);
+ reset_offline();
+ domain_search_get_file(zb, "fid", "test/downloads/domain.csv", on_get, on_error);
+ TEST_ASSERT_EQUAL_STRING("/domain-search/getfile", only_request().path);
+ reset_offline();
+ domain_search_delete_file(zb, "fid", on_delete, on_error);
+ TEST_ASSERT_EQUAL_STRING("/domain-search/deletefile", only_request().path);
+ reset_offline();
+ send_list_evaluator_file(zb, "email_file.csv", 1, on_send, on_error);
+ TEST_ASSERT_EQUAL_STRING("/v2/listevaluator/", only_request().path);
+ TEST_ASSERT_TRUE(has_field("email_address_column", "1"));
+ reset_offline();
+ get_list_evaluator_result(zb, "fid", on_raw, on_error);
+ TEST_ASSERT_NOT_NULL(strstr(g_raw, "p3-key"));
+ TEST_ASSERT_EQUAL_STRING("/v2/listevaluator/fid/", only_request().path);
+ reset_offline();
+ send_evaluated_file(zb, "email_file.csv", on_send, on_error);
+ TEST_ASSERT_EQUAL_STRING("/v2/send-evaluated-file/", only_request().path);
+ reset_offline();
+ add_filter(zb, "exclude", "domain", "example.com", on_raw, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("POST", request.method);
+ TEST_ASSERT_EQUAL_STRING("/v2/filters/add", request.path);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "rule=exclude"));
+ reset_offline();
+ list_filters(zb, false, on_raw, on_error);
+ TEST_ASSERT_EQUAL_STRING("GET", only_request().method);
+ TEST_ASSERT_EQUAL_STRING("/v2/filters/list", only_request().path);
+ reset_offline();
+ list_filters(zb, true, on_raw, on_error);
+ TEST_ASSERT_EQUAL_STRING("POST", only_request().method);
+ reset_offline();
+ delete_filter(zb, "exclude", "domain", "example.com", on_raw, on_error);
+ TEST_ASSERT_EQUAL_STRING("/v2/filters/delete", only_request().path);
+ reset_offline();
+ ValidateS3FileOptions s3 = new_validate_s3_file_options();
+ s3.file_name = "inbox.csv";
+ s3.email_address_column = 0;
+ s3.has_header_row = 0;
+ s3.remove_duplicate = 1;
+ validate_s3_file(zb, &s3, on_send, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/validateS3file", request.path);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "email_address_column=0"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "has_header_row=false"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "remove_duplicate=true"));
+ TEST_ASSERT_NULL(strstr(request.body, "first_name_column"));
+ reset_offline();
+ ScoreS3FileOptions score = new_score_s3_file_options();
+ score.file_name = "inbox.csv";
+ score_s3_file(zb, &score, on_send, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/scoreS3file", request.path);
+ TEST_ASSERT_NULL(strstr(request.body, "email_address_column"));
+ reset_offline();
+ export_s3_file(zb, "fid", "bucket-out", on_send, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/exportS3file", request.path);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "export_bucket=bucket-out"));
+ reset_offline();
+ list_s3_files(zb, NULL, NULL, on_raw, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/listS3files", request.path);
+ TEST_ASSERT_NULL(strstr(request.query, "bucket="));
+ TEST_ASSERT_NOT_NULL(strstr(g_raw, "p3-key"));
+ reset_offline();
+ list_s3_files(zb, "inbox", "csv", on_raw, on_error);
+ request = only_request();
+ TEST_ASSERT_NOT_NULL(strstr(request.query, "bucket=inbox"));
+ TEST_ASSERT_NOT_NULL(strstr(request.query, "search=csv"));
+}
+
+void test_offline_rejects_before_request(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("");
+ get_credits(zb, on_credits, on_error);
+ find_email_by_domain_first_name(zb, "example.com", "Ada", on_find, on_error);
+ search_domain_by_company_name(zb, "ZeroBounce", on_domain, on_error);
+ list_filters(zb, false, on_raw, on_error);
+ email_finder_delete_file(zb, "fid", on_delete, on_error);
+ send_domain_search_file(zb, "email_file.csv", 1, NULL, on_send, on_error);
+ ValidateS3FileOptions missing = new_validate_s3_file_options();
+ missing.file_name = "inbox.csv";
+ validate_s3_file(zb, &missing, on_send, on_error);
+ score_s3_file(zb, NULL, on_send, on_error);
+ export_s3_file(zb, "fid", "out", on_send, on_error);
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "initialize"));
+
+ reset_offline();
+ ZeroBounce *keyed = client_with("keyed");
+ ZBValidateOptions bad = new_validate_options();
+ bad.timeout = 2;
+ validate_email_with_options(keyed, "a@example.com", "", &bad, on_validate, on_error);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "timeout"));
+ ZBValidateOptions batch_bad = new_validate_options();
+ batch_bad.timeout = 9;
+ EmailToValidateVector emails = email_to_validate_vector_init();
+ ZBEmailToValidate a = {"a@example.com", ""};
+ email_to_validate_vector_append(&emails, a);
+ validate_email_batch_with_options(keyed, emails, &batch_bad, on_batch, on_error);
+ send_email_finder_file(keyed, "email_file.csv", 0, NULL, on_send, on_error);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "domain_column"));
+ ValidateS3FileOptions no_name = new_validate_s3_file_options();
+ validate_s3_file(keyed, &no_name, on_send, on_error);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name"));
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+ email_to_validate_vector_free(&emails);
+}
+
+void test_offline_html_403(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("html-403-user");
+ get_credits(zb, on_credits, on_error);
+ expect_short_error();
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Access denied"));
+ TEST_ASSERT_TRUE(strlen(html_page()) > 100000);
+}
+
+void test_offline_html_200_validate(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("html-200-user");
+ validate_email(zb, "a@example.com", "", on_validate, on_error);
+ TEST_ASSERT_FALSE(g_success_called);
+ expect_short_error();
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Access denied"));
+}
+
+void test_offline_html_200_get_file(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("html-200-file");
+ const char *path = "test/downloads/offline-html.csv";
+ remove(path);
+ get_file(zb, "fid", (char *)path, on_get, on_error);
+ TEST_ASSERT_FALSE(g_success_called);
+ expect_short_error();
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Access denied"));
+ FILE *f = fopen(path, "rb");
+ TEST_ASSERT_NULL(f);
+}
+
+void test_offline_cloudflare_json(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("cf-json-user");
+ get_credits(zb, on_credits, on_error);
+ expect_short_error();
+ TEST_ASSERT_NULL(strstr(g_error_text, "developers.cloudflare.com"));
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Access denied"));
+}
+
+void test_offline_empty_and_malformed(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("empty-user");
+ validate_email(zb, "a@example.com", "", on_validate, on_error);
+ expect_short_error();
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Empty response"));
+ reset_offline();
+ ZeroBounce *bad = client_with("malformed-user");
+ validate_email(bad, "a@example.com", "", on_validate, on_error);
+ expect_short_error();
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "not valid JSON"));
+ TEST_ASSERT_NULL(strstr(g_error_text, "<<>>"));
+}
+
+void test_offline_double_error_prefix(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("double-error-user");
+ get_activity_data(zb, "a@example.com", on_activity, on_error);
+ expect_short_error();
+ TEST_ASSERT_EQUAL_STRING("api_key is invalid", g_error_text);
+}
+
+void test_offline_batch_account_error(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("batch-account-user");
+ EmailToValidateVector emails = email_to_validate_vector_init();
+ ZBEmailToValidate a = {"a@example.com", ""};
+ email_to_validate_vector_append(&emails, a);
+ validate_email_batch(zb, emails, on_batch, on_error);
+ TEST_ASSERT_FALSE(g_success_called);
+ expect_short_error();
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Invalid API Key"));
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "ran out of credits"));
+ email_to_validate_vector_free(&emails);
+}
+
+void test_offline_batch_partial(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("batch-partial-user");
+ EmailToValidateVector emails = email_to_validate_vector_init();
+ ZBEmailToValidate a = {"good@example.com", ""};
+ ZBEmailToValidate b = {"bad@", ""};
+ email_to_validate_vector_append(&emails, a);
+ email_to_validate_vector_append(&emails, b);
+ validate_email_batch(zb, emails, on_batch, on_error);
+ TEST_ASSERT_TRUE(g_success_called);
+ TEST_ASSERT_EQUAL_STRING("good@example.com", g_echo);
+ TEST_ASSERT_EQUAL_STRING("bad@", g_error_text);
+ TEST_ASSERT_FALSE(g_error_called);
+ email_to_validate_vector_free(&emails);
+}
+
+typedef struct {
+ int index;
+ char echo[64];
+} ConcArg;
+
+static void *conc_thread(void *arg) {
+ ConcArg *item = arg;
+ char key[32];
+ snprintf(key, sizeof(key), "conc-%d", item->index);
+ ZeroBounce *zb = client_with(key);
+ tls_echo[0] = '\0';
+ validate_email(zb, "person@example.com", "", on_validate, on_error);
+ snprintf(item->echo, sizeof(item->echo), "%s", tls_echo);
+ return NULL;
+}
+
+void test_offline_concurrent_clients(void) {
+ reset_offline();
+ enum { N = 12 };
+ pthread_t threads[N];
+ ConcArg args[N];
+ for (int i = 0; i < N; i++) {
+ args[i].index = i;
+ args[i].echo[0] = '\0';
+ pthread_create(&threads[i], NULL, conc_thread, &args[i]);
+ }
+ for (int i = 0; i < N; i++) pthread_join(threads[i], NULL);
+ int seen[N] = {0};
+ TEST_ASSERT_EQUAL_INT(N, g_count);
+ for (int i = 0; i < g_count; i++) {
+ int n = -1;
+ sscanf(g_requests[i].api_key, "conc-%d", &n);
+ TEST_ASSERT_TRUE(n >= 0 && n < N);
+ seen[n]++;
+ }
+ for (int i = 0; i < N; i++) {
+ char key[32];
+ snprintf(key, sizeof(key), "conc-%d", i);
+ TEST_ASSERT_EQUAL_INT(1, seen[i]);
+ TEST_ASSERT_EQUAL_STRING(key, args[i].echo);
+ }
+}
+
+void offline_run_tests(void) {
+ RUN_TEST(test_offline_get_credits);
+ RUN_TEST(test_offline_validate_options);
+ RUN_TEST(test_offline_validate_post);
+ RUN_TEST(test_offline_batch_omits_and_keeps_zero_ip);
+ RUN_TEST(test_offline_batch_explicit_false);
+ RUN_TEST(test_offline_send_file_bools_and_zero_column);
+ RUN_TEST(test_offline_scoring_send_omits_validation_fields);
+ RUN_TEST(test_offline_file_status_and_delete);
+ RUN_TEST(test_offline_get_file_options);
+ RUN_TEST(test_offline_activity_usage_score);
+ RUN_TEST(test_offline_finder_and_domain);
+ RUN_TEST(test_offline_priority3);
+ RUN_TEST(test_offline_rejects_before_request);
+ RUN_TEST(test_offline_html_403);
+ RUN_TEST(test_offline_html_200_validate);
+ RUN_TEST(test_offline_html_200_get_file);
+ RUN_TEST(test_offline_cloudflare_json);
+ RUN_TEST(test_offline_empty_and_malformed);
+ RUN_TEST(test_offline_double_error_prefix);
+ RUN_TEST(test_offline_batch_account_error);
+ RUN_TEST(test_offline_batch_partial);
+ RUN_TEST(test_offline_concurrent_clients);
+}
diff --git a/test/ZeroBounceTest.c b/test/ZeroBounceTest.c
index 1917bd4..0418590 100644
--- a/test/ZeroBounceTest.c
+++ b/test/ZeroBounceTest.c
@@ -5,6 +5,8 @@
#include "ZeroBounce/ZeroBounce.h"
+void offline_run_tests(void);
+
DEFINE_FFF_GLOBALS;
static ZeroBounce* zb;
@@ -1134,5 +1136,7 @@ int main(void)
RUN_TEST(test_search_domain_status_valid_payload);
RUN_TEST(test_search_domain_status_serialize_functions);
+ offline_run_tests();
+
return UNITY_END();
}
\ No newline at end of file
From 3fd4a2732d7f19202c770182f4fc05859b361a6b Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sat, 24 Oct 2026 01:14:42 +0400
Subject: [PATCH 10/19] feat: validate WHOIS and greylist options, Inbox
Placement Tester, SFTP bulk
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ports 407f0a8 and b0444f8 from the Python SDK, on top of the parity work
already on this branch.
Added:
- ZBValidateOptions gains domain_info, greylist_processing and callback_url.
Booleans go on the wire as the strings true/false on both the GET and the
form POST. callback_url is where the greylist retry posts its result, so it
is rejected without greylist_processing, and when blank, before any request
is sent.
- ZBValidateResponse reads domain_website_exists,
domain_registrant_company_name and message. message is the API's "Message"
key, returned instead of a result while a greylist retry is still running.
- email_finder_s3_file and domain_search_s3_file, from
https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api
- validate_sftp_file, score_sftp_file, email_finder_sftp_file,
domain_search_sftp_file and export_sftp_file, from
https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api
- The Inbox Placement Tester. The API key goes on the query string, bodies are
camelCase JSON, unset fields are left out, and it needs PUT and DELETE, so
zb_make_request_with_method_and_body is new.
- zero_bounce_set_list_evaluator_base_url and
zero_bounce_set_inbox_placement_base_url.
Changed:
- The list evaluator uses its own base URL instead of building the path from
the bulk base, and the file id is encoded as a single path segment.
get_list_evaluator_result now rejects a blank file id before a request is
sent. send_evaluated_file stays on the bulk base.
The six S3 / SFTP file jobs share ZBBulkFileJobOptions and
p3_bulk_root_file_job, 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 rest and are easy to get wrong:
- validate_sftp_file's file_name is optional; leaving it NULL uses the input
bucket from the account's settings. Every other job requires it, and a blank
name is still rejected.
- export_sftp_file sends export_path, not the export_bucket export_s3_file
sends.
p3_escape_path_segment is new: zb_url_escape is a query encoder and turns a
space into "+", which is a literal plus inside a path. The list evaluator file
id and the Inbox Placement test id go through it.
parse_error already collects every value of an error body, so the Tester's
Django-style {"detail": ...} surfaces without a special case; a test now pins
that.
The new bulk assertions read OfflineRequest.body rather than has_field,
because p3_append_form_field does not call zb_note_http_field — that only
covers the multipart paths. AGENTS.md records this.
Tests: 70 pass, up from 63. Offline only — a fake perform hook records every
request and answers without the network.
Co-Authored-By: Claude Opus 5
---
AGENTS.md | 7 +-
CHANGELOG.md | 10 +-
README.md | 95 ++++++
documentation.md | 97 +++++-
include/ZeroBounce/ZeroBounce.h | 187 ++++++++++++
include/ZeroBounce/zb_internal.h | 15 +
src/Priority3.c | 506 ++++++++++++++++++++++++++++++-
src/ZeroBounce.c | 140 ++++++++-
test/OfflineApiTest.c | 281 +++++++++++++++++
9 files changed, 1328 insertions(+), 10 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 1afb75e..78d0873 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.c`: validation, bulk file, credits, activity, scoring, and the libcurl write callback.
-- `src/Priority3.c`: bulk email finder, domain search, list evaluator, filters, S3.
+- `src/Priority3.c`: bulk email finder, domain search, list evaluator, filters, the S3 / SFTP bulk jobs, and the Inbox Placement Tester. The six file jobs share `ZBBulkFileJobOptions` and `p3_bulk_root_file_job`.
- `src/ZBErrorResponse.c`: turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short `ZBErrorResponse`.
- `test/ZeroBounceTest.c`: existing Unity tests.
- `test/OfflineApiTest.c`: offline suite. `zero_bounce_set_http_perform_for_test` replaces libcurl and records every request.
@@ -29,6 +29,11 @@ CI (`.github/workflows/sdk_ci.yml`) configures, builds, and runs `./build/bin/Ze
## Conventions
- **One `ZeroBounce` per API key.** `zero_bounce_get_instance()` is a process-wide singleton. Construct a separate object with `new_zero_bounce_instance` per key. The offline concurrency test does this.
+- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a function for an endpoint the public docs do not describe. Check before concluding something is undocumented: the S3 and SFTP bulk endpoints live under https://www.zerobounce.net/docs/api-integrations, not the main API reference.
+- **Four base URLs on `ZeroBounce`.** `api_base_url`, `bulk_api_base_url` (ends in `/v2`), `bulk_api_root_url` (the S3/SFTP jobs, **no** `/v2`), and `list_evaluator_base_url` / `inbox_placement_base_url`. The last two are separate hosts rather than paths built from the bulk base, so `zero_bounce_initialize_with_base_url` alone leaves them on the live API — use the two setters in tests.
+- **`zb_url_escape` is a query encoder.** It turns a space into `+`, which is correct in a query string but a literal plus in a path. Use `p3_escape_path_segment` for anything that goes into a path, such as the list evaluator file id and the Inbox Placement test id.
+- **Two bulk shapes are easy to get wrong.** `validate_sftp_file`'s `file_name` is optional — leaving it `NULL` uses the input bucket from the account's settings, and every other job requires it. `export_sftp_file` sends `export_path`, not the `export_bucket` that `export_s3_file` sends.
+- **`p3_append_form_field` does not call `zb_note_http_field`.** The offline tests assert on the recorded request body (`OfflineRequest.body`), not on `has_field`, for anything built with it.
- **Column indexes are 1-based** on bulk send-file name, gender, and IP columns. `0` means the field is left out. `send_file`'s email column is required and is sent as given, including `0`. `ValidateS3FileOptions.email_address_column` uses `-1` for omit, and `0` is sent.
- **Tri-state ints are `-1` omit, `0` false, `1` true.** `timeout`, `activity_data`, `verify_plus`, `has_header_row`, and `allow_phase_2` follow that. Explicit `0` is sent as `false`.
- **Booleans on multipart and form bodies are the strings `true` and `false`.**
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2d7dcf1..1427666 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,10 +16,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bulk domain search (`send_domain_search_file`, `domain_search_file_status`, `domain_search_get_file`, `domain_search_delete_file`).
- List evaluator (`send_list_evaluator_file`, `get_list_evaluator_result`, `send_evaluated_file`).
- Filters (`add_filter`, `list_filters`, `delete_filter`).
-- S3 bulk (`validate_s3_file`, `score_s3_file`, `export_s3_file`, `list_s3_files`).
+- S3 bulk (`validate_s3_file`, `score_s3_file`, `export_s3_file`, `list_s3_files`, `email_finder_s3_file`, `domain_search_s3_file`).
+- SFTP/FTP bulk (`validate_sftp_file`, `score_sftp_file`, `email_finder_sftp_file`, `domain_search_sftp_file`, `export_sftp_file`), sharing the new `ZBBulkFileJobOptions`. The server, path and protocol come from your ZeroBounce settings. `validate_sftp_file`'s `file_name` is optional; `export_sftp_file` takes `export_path` rather than the `export_bucket` `export_s3_file` sends.
+- `ZBValidateOptions` accepts `domain_info`, `greylist_processing` and `callback_url`. `callback_url` needs `greylist_processing` set to 1 and is rejected without it, and when blank, before a request is sent.
+- `ZBValidateResponse` reads `domain_website_exists`, `domain_registrant_company_name` and `message`. `message` is the API's `Message` key, returned instead of a result while a greylist retry is still running.
+- Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `get_inbox_placement_providers`, `get_inbox_placement_test_types`, `create_inbox_placement_test`, `get_inbox_placement_test`, `list_inbox_placement_tests`, `delete_inbox_placement_test`, `get_inbox_placement_webhook`, `set_inbox_placement_webhook`. The API key goes on the query string, bodies are camelCase JSON, and unset fields are left out.
+- `zero_bounce_set_list_evaluator_base_url` and `zero_bounce_set_inbox_placement_base_url`.
+- `zb_make_request_with_method_and_body`, for the Tester's PUT and DELETE.
### Changed
+- The list evaluator uses its own base URL (`.../v2/listevaluator`) instead of building the path from the bulk base, and the file id is encoded as a single path segment. `send_evaluated_file` stays on the bulk base.
+
- API requests time out after 120 seconds.
- Query values, including the API key, email, and file id, are percent-encoded.
- JSON API responses are capped at 16 MB and bulk file downloads at 256 MB.
diff --git a/README.md b/README.md
index a726d28..1e1c6c6 100644
--- a/README.md
+++ b/README.md
@@ -349,6 +349,101 @@ search_domain_by_company_name(
```
+## Validate options
+
+`validate_email_with_options` takes a `ZBValidateOptions`. A tri-state field is
+`-1` to omit, `0` false and `1` true; booleans go on the wire as the strings
+`"true"`/`"false"`.
+
+```c
+ZBValidateOptions options = new_validate_options();
+options.timeout = 30; /* 3-60 seconds */
+options.activity_data = 1; /* fills active_in_days / active_first_seen */
+options.domain_info = 1; /* fills domain_website_exists / domain_registrant_company_name */
+options.greylist_processing = 1;
+options.callback_url = "https://example.com/greylist";
+options.use_post = 1; /* form-encoded POST /v2/validate instead of GET */
+
+validate_email_with_options(zb, "", "", &options, on_validate, on_error);
+```
+
+`callback_url` is where the greylist retry posts its result, so it is rejected
+without `greylist_processing = 1` — before any request is sent. While that retry
+is still running the API answers with a `Message` instead of a result, which
+lands on `response.message`.
+
+## 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.
+These live on the bulkapi host *without* the `/v2` path.
+
+```c
+ZBBulkFileJobOptions options = new_bulk_file_job_options();
+
+/* Amazon S3. file_name is the full path, as returned by list_s3_files. */
+options.file_name = "lists/leads.csv";
+email_finder_s3_file(zb, &options, on_send, on_error);
+
+options.remove_duplicates = 1;
+options.return_url = "https://example.com/zerobounce-done";
+domain_search_s3_file(zb, &options, on_send, on_error);
+
+/* SFTP/FTP. The server, path and protocol come from your ZeroBounce settings.
+ file_name may be left NULL for validate_sftp_file alone, which then uses the
+ input bucket from those settings. */
+options = new_bulk_file_job_options();
+validate_sftp_file(zb, &options, on_send, on_error);
+
+options.file_name = "my_file.txt";
+score_sftp_file(zb, &options, on_send, on_error);
+email_finder_sftp_file(zb, &options, on_send, on_error);
+domain_search_sftp_file(zb, &options, on_send, on_error);
+
+/* Note the field: this one takes export_path, not export_bucket.
+ NULL uses the export path from your settings. */
+export_sftp_file(zb, "", "/exports", on_send, on_error);
+```
+
+## Inbox Placement Tester
+
+Runs on its own host, takes the API key on the query string, and sends camelCase
+JSON bodies. It spends the account's inbox-test credits.
+
+```c
+get_inbox_placement_providers(zb, on_raw, on_error);
+get_inbox_placement_test_types(zb, on_raw, on_error);
+
+ZBCreateInboxPlacementTestOptions create = new_create_inbox_placement_test_options();
+create.test_type = 1;
+create.sender_email = "news@example.com";
+create.webhook_url = "https://example.com/ipt-webhook";
+create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error);
+
+/* Send your campaign to the seeds you got back, then read the results. */
+get_inbox_placement_test(zb, "", on_raw, on_error);
+
+ZBListInboxPlacementTestsOptions list = new_list_inbox_placement_tests_options();
+list.status = "finished";
+list.page = 1;
+list_inbox_placement_tests(zb, &list, on_raw, on_error);
+
+set_inbox_placement_webhook(zb, "https://example.com/ipt-webhook", on_raw, on_error);
+get_inbox_placement_webhook(zb, on_raw, on_error);
+delete_inbox_placement_test(zb, "", on_raw, on_error);
+```
+
+## Per-API base URLs
+
+The list evaluator and the Inbox Placement Tester are separate hosts, so
+`zero_bounce_initialize_with_base_url` alone leaves them pointing at the live API.
+
+```c
+zero_bounce_set_list_evaluator_base_url(zb, "https://bulkapi.zerobounce.net/v2/listevaluator");
+zero_bounce_set_inbox_placement_base_url(zb, "https://bulkapi.zerobounce.net/api/ipt");
+```
+
## DEVELOPMENT
It is recommended that the development should be done on Linux.
diff --git a/documentation.md b/documentation.md
index ce33f61..29d2cd5 100644
--- a/documentation.md
+++ b/documentation.md
@@ -337,7 +337,102 @@ search_domain_by_company_name(
);
```
-#### DEVELOPMENT
+#### Validate options
+
+`validate_email_with_options` takes a `ZBValidateOptions`. A tri-state field is
+`-1` to omit, `0` false and `1` true; booleans go on the wire as the strings
+`"true"`/`"false"`.
+
+```c
+ZBValidateOptions options = new_validate_options();
+options.timeout = 30; /* 3-60 seconds */
+options.activity_data = 1; /* fills active_in_days / active_first_seen */
+options.domain_info = 1; /* fills domain_website_exists / domain_registrant_company_name */
+options.greylist_processing = 1;
+options.callback_url = "https://example.com/greylist";
+options.use_post = 1; /* form-encoded POST /v2/validate instead of GET */
+
+validate_email_with_options(zb, "", "", &options, on_validate, on_error);
+```
+
+`callback_url` is where the greylist retry posts its result, so it is rejected
+without `greylist_processing = 1` — before any request is sent. While that retry
+is still running the API answers with a `Message` instead of a result, which
+lands on `response.message`.
+
+## 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.
+These live on the bulkapi host *without* the `/v2` path.
+
+```c
+ZBBulkFileJobOptions options = new_bulk_file_job_options();
+
+/* Amazon S3. file_name is the full path, as returned by list_s3_files. */
+options.file_name = "lists/leads.csv";
+email_finder_s3_file(zb, &options, on_send, on_error);
+
+options.remove_duplicates = 1;
+options.return_url = "https://example.com/zerobounce-done";
+domain_search_s3_file(zb, &options, on_send, on_error);
+
+/* SFTP/FTP. The server, path and protocol come from your ZeroBounce settings.
+ file_name may be left NULL for validate_sftp_file alone, which then uses the
+ input bucket from those settings. */
+options = new_bulk_file_job_options();
+validate_sftp_file(zb, &options, on_send, on_error);
+
+options.file_name = "my_file.txt";
+score_sftp_file(zb, &options, on_send, on_error);
+email_finder_sftp_file(zb, &options, on_send, on_error);
+domain_search_sftp_file(zb, &options, on_send, on_error);
+
+/* Note the field: this one takes export_path, not export_bucket.
+ NULL uses the export path from your settings. */
+export_sftp_file(zb, "", "/exports", on_send, on_error);
+```
+
+## Inbox Placement Tester
+
+Runs on its own host, takes the API key on the query string, and sends camelCase
+JSON bodies. It spends the account's inbox-test credits.
+
+```c
+get_inbox_placement_providers(zb, on_raw, on_error);
+get_inbox_placement_test_types(zb, on_raw, on_error);
+
+ZBCreateInboxPlacementTestOptions create = new_create_inbox_placement_test_options();
+create.test_type = 1;
+create.sender_email = "news@example.com";
+create.webhook_url = "https://example.com/ipt-webhook";
+create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error);
+
+/* Send your campaign to the seeds you got back, then read the results. */
+get_inbox_placement_test(zb, "", on_raw, on_error);
+
+ZBListInboxPlacementTestsOptions list = new_list_inbox_placement_tests_options();
+list.status = "finished";
+list.page = 1;
+list_inbox_placement_tests(zb, &list, on_raw, on_error);
+
+set_inbox_placement_webhook(zb, "https://example.com/ipt-webhook", on_raw, on_error);
+get_inbox_placement_webhook(zb, on_raw, on_error);
+delete_inbox_placement_test(zb, "", on_raw, on_error);
+```
+
+## Per-API base URLs
+
+The list evaluator and the Inbox Placement Tester are separate hosts, so
+`zero_bounce_initialize_with_base_url` alone leaves them pointing at the live API.
+
+```c
+zero_bounce_set_list_evaluator_base_url(zb, "https://bulkapi.zerobounce.net/v2/listevaluator");
+zero_bounce_set_inbox_placement_base_url(zb, "https://bulkapi.zerobounce.net/api/ipt");
+```
+
+## DEVELOPMENT
It is recommended that the development should be done on Linux.
diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h
index b156184..5bc0a9b 100644
--- a/include/ZeroBounce/ZeroBounce.h
+++ b/include/ZeroBounce/ZeroBounce.h
@@ -93,6 +93,17 @@ typedef struct {
int timeout;
int activity_data;
int verify_plus;
+ /** -1 omit, 0 false, 1 true. Fills domain_website_exists and
+ * domain_registrant_company_name on the response. */
+ int domain_info;
+ /** -1 omit, 0 false, 1 true. Retries a greylisted address in the background. */
+ int greylist_processing;
+ /**
+ * Where the greylist retry posts its result. NULL to omit. It needs
+ * greylist_processing set to 1 and is rejected without it, and when blank,
+ * before any request is sent.
+ */
+ const char *callback_url;
/** -1 omit, 1 use form-encoded POST for /validate */
int use_post;
} ZBValidateOptions;
@@ -161,6 +172,13 @@ typedef struct {
char* api_key;
char* api_base_url;
const char* bulk_api_root_url;
+ /**
+ * The list evaluator and the Inbox Placement Tester have their own base
+ * URLs, not paths built from the bulk one. Overriding api_base_url alone
+ * leaves them pointing at the live API.
+ */
+ const char* list_evaluator_base_url;
+ const char* inbox_placement_base_url;
const char* bulk_api_base_url;
const char* bulk_api_scoring_base_url;
} ZeroBounce;
@@ -910,6 +928,175 @@ void list_s3_files(
OnErrorCallback error_callback
);
+/** @brief Overrides the list evaluator base URL. */
+void zero_bounce_set_list_evaluator_base_url(ZeroBounce *zb, const char *url);
+
+/** @brief Overrides the Inbox Placement Tester base URL. */
+void zero_bounce_set_inbox_placement_base_url(ZeroBounce *zb, const char *url);
+
+/**
+ * @brief The documented fields shared by the six S3 and SFTP/FTP file jobs.
+ *
+ * file_name is required by every job except validate_sftp_file, where the docs
+ * list it as optional: leaving it NULL there uses the input bucket configured
+ * in your ZeroBounce settings. remove_duplicates is -1 to omit, 0 false, 1 true.
+ */
+typedef struct {
+ const char *file_name;
+ bool has_header_row;
+ int remove_duplicates;
+ /** NULL to omit. */
+ const char *return_url;
+} ZBBulkFileJobOptions;
+
+/** @brief has_header_row true, remove_duplicates and return_url omitted. */
+ZBBulkFileJobOptions new_bulk_file_job_options(void);
+
+/* S3 bulk — https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api */
+
+void email_finder_s3_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void domain_search_s3_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+/* SFTP/FTP bulk — https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api
+ * The server, path and protocol are never sent by the SDK; they come from your
+ * ZeroBounce settings. */
+
+/**
+ * @brief Validates a file from your SFTP/FTP server.
+ *
+ * This is the one job whose file_name the docs list as optional: leave
+ * options->file_name NULL to use the input bucket from your settings.
+ */
+void validate_sftp_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void score_sftp_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void email_finder_sftp_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+void domain_search_sftp_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+/**
+ * @brief Exports a validated or scored file back to your SFTP/FTP server.
+ *
+ * Note the field name: this endpoint takes export_path, not the export_bucket
+ * that export_s3_file sends. Pass NULL to use the one from your settings.
+ */
+void export_sftp_file(
+ ZeroBounce *zb,
+ const char *file_id,
+ const char *export_path,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+);
+
+/* Inbox Placement Tester (/api/ipt). Its own host, the API key on the query
+ * string, camelCase JSON bodies, and errors under a "detail" key. Spends the
+ * account's inbox-test credits. */
+
+/** @brief The optional fields of a new test. Only what is set is sent. */
+typedef struct {
+ /** -1 to omit. */
+ int test_type;
+ const char *sender_email;
+ const char *webhook_url;
+} ZBCreateInboxPlacementTestOptions;
+
+ZBCreateInboxPlacementTestOptions new_create_inbox_placement_test_options(void);
+
+/** @brief The documented filters of the test list. page / page_size: -1 to omit. */
+typedef struct {
+ const char *status;
+ int page;
+ int page_size;
+} ZBListInboxPlacementTestsOptions;
+
+ZBListInboxPlacementTestsOptions new_list_inbox_placement_tests_options(void);
+
+void get_inbox_placement_providers(
+ ZeroBounce *zb,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void get_inbox_placement_test_types(
+ ZeroBounce *zb,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void create_inbox_placement_test(
+ ZeroBounce *zb,
+ const char *name,
+ const ZBCreateInboxPlacementTestOptions *options,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void get_inbox_placement_test(
+ ZeroBounce *zb,
+ const char *test_id,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void list_inbox_placement_tests(
+ ZeroBounce *zb,
+ const ZBListInboxPlacementTestsOptions *options,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void delete_inbox_placement_test(
+ ZeroBounce *zb,
+ const char *test_id,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void get_inbox_placement_webhook(
+ ZeroBounce *zb,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
+void set_inbox_placement_webhook(
+ ZeroBounce *zb,
+ const char *webhook_url,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+);
+
/**
* @brief Email Address Search - Identifies and validates a person’s primary email address
*
diff --git a/include/ZeroBounce/zb_internal.h b/include/ZeroBounce/zb_internal.h
index e062e29..62b176f 100644
--- a/include/ZeroBounce/zb_internal.h
+++ b/include/ZeroBounce/zb_internal.h
@@ -13,6 +13,21 @@ int zb_make_request(
long *http_code,
OnErrorCallback error_callback
);
+/*
+ * A request with an explicit method and an optional body. Needed by the Inbox
+ * Placement Tester, which uses PUT and DELETE and sends JSON rather than a
+ * form body.
+ */
+int zb_make_request_with_method_and_body(
+ char* url_path,
+ const char* method,
+ const char* body,
+ const char* content_type_header,
+ memory* response_data,
+ long* http_code,
+ OnErrorCallback error_callback
+);
+
int zb_make_request_with_post_body(
char *url_path,
char *body,
diff --git a/src/Priority3.c b/src/Priority3.c
index b467f26..a1a5ff3 100644
--- a/src/Priority3.c
+++ b/src/Priority3.c
@@ -1,3 +1,4 @@
+#include
#include
#include
#include
@@ -8,6 +9,9 @@
#include "ZeroBounce/ZeroBounce.h"
#include "ZeroBounce/zb_internal.h"
+static bool p3_is_blank(const char *value);
+static char *p3_escape_path_segment(const char *value);
+
typedef void (*p3_mime_fields_fn)(curl_mime *multipart, void *ctx);
EmailFinderSendFileOptions new_email_finder_send_file_options(void) {
@@ -681,12 +685,25 @@ void get_list_evaluator_result(
if (zero_bounce_invalid_api_key(zb, error_callback)) {
return;
}
- char *api_key_esc = zb_url_escape(zb->api_key);
- char *file_id_esc = zb_url_escape(file_id);
- const char *url_pattern = "%s/listevaluator/%s/?api_key=%s";
- int url_path_len = snprintf(NULL, 0, url_pattern, zb->bulk_api_base_url, file_id_esc, api_key_esc);
- char *url_path = malloc((size_t)url_path_len + 1);
- snprintf(url_path, (size_t)url_path_len + 1, url_pattern, zb->bulk_api_base_url, file_id_esc, api_key_esc);
+ char *api_key_esc;
+ char *file_id_esc;
+ const char *url_pattern = "%s/%s/?api_key=%s";
+ int url_path_len;
+ char *url_path;
+
+ if (p3_is_blank(file_id)) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: file_id"));
+ }
+ return;
+ }
+
+ api_key_esc = zb_url_escape(zb->api_key);
+ /* The file id is one path segment, so a slash in it must stay encoded. */
+ file_id_esc = p3_escape_path_segment(file_id);
+ url_path_len = snprintf(NULL, 0, url_pattern, zb->list_evaluator_base_url, file_id_esc, api_key_esc);
+ url_path = malloc((size_t)url_path_len + 1);
+ snprintf(url_path, (size_t)url_path_len + 1, url_pattern, zb->list_evaluator_base_url, file_id_esc, api_key_esc);
free(api_key_esc);
free(file_id_esc);
p3_raw_json_get(zb, url_path, success_callback, error_callback);
@@ -962,3 +979,480 @@ void export_s3_file(
snprintf(url, sizeof(url), "%s/exportS3file", zb->bulk_api_root_url);
p3_post_send_file_form(zb, url, body, success_callback, error_callback);
}
+
+/* ------------------------------------------------------------------ */
+/* S3 email finder / domain search, SFTP/FTP bulk, Inbox Placement */
+/* Ported from the Python SDK (b0444f8 and 407f0a8). */
+/* ------------------------------------------------------------------ */
+
+void zero_bounce_set_list_evaluator_base_url(ZeroBounce *zb, const char *url) {
+ if (zb && url) {
+ zb->list_evaluator_base_url = url;
+ }
+}
+
+void zero_bounce_set_inbox_placement_base_url(ZeroBounce *zb, const char *url) {
+ if (zb && url) {
+ zb->inbox_placement_base_url = url;
+ }
+}
+
+ZBBulkFileJobOptions new_bulk_file_job_options(void) {
+ ZBBulkFileJobOptions o;
+ o.file_name = NULL;
+ o.has_header_row = true;
+ o.remove_duplicates = -1;
+ o.return_url = NULL;
+ return o;
+}
+
+ZBCreateInboxPlacementTestOptions new_create_inbox_placement_test_options(void) {
+ ZBCreateInboxPlacementTestOptions o;
+ o.test_type = -1;
+ o.sender_email = NULL;
+ o.webhook_url = NULL;
+ return o;
+}
+
+ZBListInboxPlacementTestsOptions new_list_inbox_placement_tests_options(void) {
+ ZBListInboxPlacementTestsOptions o;
+ o.status = NULL;
+ o.page = -1;
+ o.page_size = -1;
+ return o;
+}
+
+static bool p3_is_blank(const char *value) {
+ const char *p;
+ if (!value) {
+ return true;
+ }
+ for (p = value; *p; p++) {
+ if (!isspace((unsigned char)*p)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/*
+ * POSTs one of the six documented S3 / SFTP file jobs on the bulkapi root.
+ *
+ * Booleans go on the wire as the strings "true"/"false", and an option the
+ * caller left unset is not sent at all. file_name_required is false only for
+ * validateSFTPfile, where the docs make the name optional.
+ */
+static void p3_bulk_root_file_job(
+ ZeroBounce *zb,
+ const char *path,
+ const ZBBulkFileJobOptions *options,
+ bool file_name_required,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ char *body = NULL;
+ size_t cap = 0;
+ char url[512];
+ bool blank;
+
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ if (!options) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: file_name"));
+ }
+ return;
+ }
+ blank = options->file_name != NULL && p3_is_blank(options->file_name);
+ if ((options->file_name == NULL && file_name_required) || blank) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: file_name"));
+ }
+ return;
+ }
+
+ p3_append_form_field(&body, &cap, "api_key", zb->api_key);
+ p3_append_form_field(&body, &cap, "has_header_row", options->has_header_row ? "true" : "false");
+ if (options->file_name) {
+ p3_append_form_field(&body, &cap, "file_name", options->file_name);
+ }
+ p3_append_optional_bool_form(&body, &cap, "remove_duplicates", options->remove_duplicates);
+ if (options->return_url) {
+ p3_append_form_field(&body, &cap, "return_url", options->return_url);
+ }
+
+ snprintf(url, sizeof(url), "%s%s", zb->bulk_api_root_url, path);
+ p3_post_send_file_form(zb, url, body, success_callback, error_callback);
+}
+
+static void p3_export_bulk_file(
+ ZeroBounce *zb,
+ const char *path,
+ const char *destination_field,
+ const char *file_id,
+ const char *destination,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ char *body = NULL;
+ size_t cap = 0;
+ char url[512];
+
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ if (p3_is_blank(file_id)) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: file_id"));
+ }
+ return;
+ }
+ if (destination && p3_is_blank(destination)) {
+ if (error_callback) {
+ char message[64];
+ snprintf(message, sizeof(message), "Empty parameter: %s", destination_field);
+ error_callback(parse_error(message));
+ }
+ return;
+ }
+
+ p3_append_form_field(&body, &cap, "api_key", zb->api_key);
+ p3_append_form_field(&body, &cap, "file_id", file_id);
+ if (destination) {
+ p3_append_form_field(&body, &cap, destination_field, destination);
+ }
+
+ snprintf(url, sizeof(url), "%s%s", zb->bulk_api_root_url, path);
+ p3_post_send_file_form(zb, url, body, success_callback, error_callback);
+}
+
+void email_finder_s3_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ p3_bulk_root_file_job(zb, "/emailFinderS3file", options, true, success_callback, error_callback);
+}
+
+void domain_search_s3_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ p3_bulk_root_file_job(zb, "/domainSearchS3file", options, true, success_callback, error_callback);
+}
+
+void validate_sftp_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ /* The one job whose file_name the docs make optional. */
+ p3_bulk_root_file_job(zb, "/validateSFTPfile", options, false, success_callback, error_callback);
+}
+
+void score_sftp_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ p3_bulk_root_file_job(zb, "/scoreSFTPfile", options, true, success_callback, error_callback);
+}
+
+void email_finder_sftp_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ p3_bulk_root_file_job(zb, "/emailFinderSFTPfile", options, true, success_callback, error_callback);
+}
+
+void domain_search_sftp_file(
+ ZeroBounce *zb,
+ const ZBBulkFileJobOptions *options,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ p3_bulk_root_file_job(zb, "/domainSearchSFTPfile", options, true, success_callback, error_callback);
+}
+
+void export_sftp_file(
+ ZeroBounce *zb,
+ const char *file_id,
+ const char *export_path,
+ OnSuccessCallbackSendFile success_callback,
+ OnErrorCallback error_callback
+) {
+ p3_export_bulk_file(
+ zb, "/exportSFTPfile", "export_path", file_id, export_path,
+ success_callback, error_callback
+ );
+}
+
+/*
+ * One Inbox Placement Tester call.
+ *
+ * Unlike the rest of the SDK this one puts the API key on the query string,
+ * sends camelCase JSON bodies, and uses PUT and DELETE as well as GET and
+ * POST, so it builds the request itself.
+ */
+static void p3_inbox_placement_request(
+ ZeroBounce *zb,
+ const char *method,
+ const char *path,
+ const char *extra_query,
+ const char *body,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ char *api_key_esc;
+ char *url_path;
+ int url_len;
+ memory response_data = {0};
+ long http_code = 0;
+
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+
+ api_key_esc = zb_url_escape(zb->api_key);
+ url_len = snprintf(
+ NULL, 0, "%s%s?api_key=%s%s",
+ zb->inbox_placement_base_url, path, api_key_esc, extra_query ? extra_query : ""
+ );
+ url_path = malloc((size_t)url_len + 1);
+ if (!url_path) {
+ free(api_key_esc);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ snprintf(
+ url_path, (size_t)url_len + 1, "%s%s?api_key=%s%s",
+ zb->inbox_placement_base_url, path, api_key_esc, extra_query ? extra_query : ""
+ );
+ free(api_key_esc);
+
+ if (!zb_make_request_with_method_and_body(
+ url_path, method, body, "Content-Type: application/json",
+ &response_data, &http_code, error_callback)) {
+ free(response_data.response);
+ free(url_path);
+ return;
+ }
+
+ /* DELETE answers with an empty body. */
+ if (http_code <= 299 && (!response_data.response || response_data.response[0] == '\0')) {
+ if (success_callback) {
+ success_callback("{}");
+ }
+ } else {
+ p3_send_json_success(&response_data, http_code, success_callback, error_callback);
+ }
+ free(response_data.response);
+ free(url_path);
+}
+
+/*
+ * URL-encodes a value as a single path segment.
+ *
+ * zb_url_escape is a query encoder and turns a space into '+', which is a
+ * literal plus inside a path, so each one becomes %20 here.
+ */
+static char *p3_escape_path_segment(const char *value) {
+ char *escaped = zb_url_escape(value);
+ size_t len = strlen(escaped);
+ char *out = malloc(len * 3 + 1);
+ size_t i;
+ size_t j = 0;
+
+ if (!out) {
+ free(escaped);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ for (i = 0; i < len; i++) {
+ if (escaped[i] == '+') {
+ out[j++] = '%';
+ out[j++] = '2';
+ out[j++] = '0';
+ } else {
+ out[j++] = escaped[i];
+ }
+ }
+ out[j] = '\0';
+ free(escaped);
+ return out;
+}
+
+static char *p3_inbox_placement_test_segment(const char *test_id, OnErrorCallback error_callback) {
+ if (p3_is_blank(test_id)) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: test_id"));
+ }
+ return NULL;
+ }
+ return p3_escape_path_segment(test_id);
+}
+
+static void p3_json_append(char **body, size_t *cap, const char *fragment) {
+ size_t needed = (*body ? strlen(*body) : 0) + strlen(fragment) + 1;
+ if (needed > *cap) {
+ size_t new_cap = needed * 2;
+ char *grown = realloc(*body, new_cap);
+ if (!grown) {
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ if (!*body) {
+ grown[0] = '\0';
+ }
+ *body = grown;
+ *cap = new_cap;
+ }
+ strcat(*body, fragment);
+}
+
+void get_inbox_placement_providers(
+ ZeroBounce *zb,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ p3_inbox_placement_request(zb, "GET", "/providers", NULL, NULL, success_callback, error_callback);
+}
+
+void get_inbox_placement_test_types(
+ ZeroBounce *zb,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ p3_inbox_placement_request(zb, "GET", "/test-types", NULL, NULL, success_callback, error_callback);
+}
+
+void create_inbox_placement_test(
+ ZeroBounce *zb,
+ const char *name,
+ const ZBCreateInboxPlacementTestOptions *options,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ char *body = NULL;
+ size_t cap = 0;
+ char buf[1024];
+
+ if (p3_is_blank(name)) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: name"));
+ }
+ return;
+ }
+
+ /* camelCase JSON, with unset fields left out. */
+ snprintf(buf, sizeof(buf), "{\"name\":\"%s\"", name);
+ p3_json_append(&body, &cap, buf);
+ if (options) {
+ if (options->test_type >= 0) {
+ snprintf(buf, sizeof(buf), ",\"testType\":%d", options->test_type);
+ p3_json_append(&body, &cap, buf);
+ }
+ if (options->sender_email) {
+ snprintf(buf, sizeof(buf), ",\"senderEmail\":\"%s\"", options->sender_email);
+ p3_json_append(&body, &cap, buf);
+ }
+ if (options->webhook_url) {
+ snprintf(buf, sizeof(buf), ",\"webhookUrl\":\"%s\"", options->webhook_url);
+ p3_json_append(&body, &cap, buf);
+ }
+ }
+ p3_json_append(&body, &cap, "}");
+
+ p3_inbox_placement_request(zb, "POST", "/test", NULL, body, success_callback, error_callback);
+ free(body);
+}
+
+void get_inbox_placement_test(
+ ZeroBounce *zb,
+ const char *test_id,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ char path[512];
+ char *segment = p3_inbox_placement_test_segment(test_id, error_callback);
+ if (!segment) {
+ return;
+ }
+ snprintf(path, sizeof(path), "/test/%s", segment);
+ free(segment);
+ p3_inbox_placement_request(zb, "GET", path, NULL, NULL, success_callback, error_callback);
+}
+
+void list_inbox_placement_tests(
+ ZeroBounce *zb,
+ const ZBListInboxPlacementTestsOptions *options,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ char query[512];
+ size_t used = 0;
+ query[0] = '\0';
+ if (options) {
+ if (options->status) {
+ char *escaped = zb_url_escape(options->status);
+ used += (size_t)snprintf(query + used, sizeof(query) - used, "&status=%s", escaped);
+ free(escaped);
+ }
+ if (options->page >= 0) {
+ used += (size_t)snprintf(query + used, sizeof(query) - used, "&page=%d", options->page);
+ }
+ if (options->page_size >= 0) {
+ snprintf(query + used, sizeof(query) - used, "&pageSize=%d", options->page_size);
+ }
+ }
+ p3_inbox_placement_request(zb, "GET", "/test", query, NULL, success_callback, error_callback);
+}
+
+void delete_inbox_placement_test(
+ ZeroBounce *zb,
+ const char *test_id,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ char path[512];
+ char *segment = p3_inbox_placement_test_segment(test_id, error_callback);
+ if (!segment) {
+ return;
+ }
+ snprintf(path, sizeof(path), "/test/%s", segment);
+ free(segment);
+ p3_inbox_placement_request(zb, "DELETE", path, NULL, NULL, success_callback, error_callback);
+}
+
+void get_inbox_placement_webhook(
+ ZeroBounce *zb,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ p3_inbox_placement_request(zb, "GET", "/webhook", NULL, NULL, success_callback, error_callback);
+}
+
+void set_inbox_placement_webhook(
+ ZeroBounce *zb,
+ const char *webhook_url,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ char body[1024];
+ if (p3_is_blank(webhook_url)) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: webhook_url"));
+ }
+ return;
+ }
+ snprintf(body, sizeof(body), "{\"webhookUrl\":\"%s\"}", webhook_url);
+ p3_inbox_placement_request(zb, "PUT", "/webhook", NULL, body, success_callback, error_callback);
+}
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index d5ff911..198c890 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -3,6 +3,7 @@
#include
#include
#include
+#include
#include
#include
@@ -232,10 +233,75 @@ GetFileOptions new_get_file_options(void) {
}
ZBValidateOptions new_validate_options(void) {
- ZBValidateOptions o = { -1, -1, -1, -1 };
+ ZBValidateOptions o = { -1, -1, -1, -1, -1, NULL, -1 };
return o;
}
+/*
+ * The callback is where the greylist retry posts its result, so it is
+ * meaningless - and rejected by the API - without the retry itself. Checked
+ * before any request is sent.
+ */
+static bool zb_check_callback_url(
+ const ZBValidateOptions *options, OnErrorCallback error_callback
+) {
+ const char *url;
+ const char *p;
+ if (!options || !options->callback_url) {
+ return true;
+ }
+ url = options->callback_url;
+ for (p = url; *p; p++) {
+ if (!isspace((unsigned char)*p)) {
+ break;
+ }
+ }
+ if (*p == '\0') {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: callback_url"));
+ }
+ return false;
+ }
+ if (options->greylist_processing != 1) {
+ if (error_callback) {
+ error_callback(parse_error(
+ "Invalid parameter: callback_url requires greylist_processing to be true"
+ ));
+ }
+ return false;
+ }
+ return true;
+}
+
+/* Appends the three options this port adds to a query string or a form body. */
+static bool zb_append_validate_extras(char **target, const ZBValidateOptions *options) {
+ if (options->domain_info >= 0) {
+ if (!zb_url_append_suffix(
+ target, options->domain_info ? "&domain_info=true" : "&domain_info=false")) {
+ return false;
+ }
+ }
+ if (options->greylist_processing >= 0) {
+ if (!zb_url_append_suffix(
+ target,
+ options->greylist_processing
+ ? "&greylist_processing=true"
+ : "&greylist_processing=false")) {
+ return false;
+ }
+ }
+ if (options->callback_url) {
+ char *escaped = zb_url_escape(options->callback_url);
+ bool ok = zb_url_append_suffix(target, "&callback_url=")
+ && zb_url_append_suffix(target, escaped);
+ free(escaped);
+ if (!ok) {
+ return false;
+ }
+ }
+ return true;
+}
+
static bool zb_check_validate_timeout(int timeout, OnErrorCallback error_callback) {
if (timeout < 0 || (timeout >= 3 && timeout <= 60)) {
return true;
@@ -473,6 +539,8 @@ ZeroBounce* new_zero_bounce_instance() {
zb->api_key = NULL;
zb->api_base_url = "https://api.zerobounce.net/v2";
zb->bulk_api_root_url = "https://bulkapi.zerobounce.net";
+ zb->list_evaluator_base_url = "https://bulkapi.zerobounce.net/v2/listevaluator";
+ zb->inbox_placement_base_url = "https://bulkapi.zerobounce.net/api/ipt";
zb->bulk_api_base_url = "https://bulkapi.zerobounce.net/v2";
zb->bulk_api_scoring_base_url = "https://bulkapi.zerobounce.net/v2/scoring";
@@ -518,6 +586,62 @@ bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback)
}
+int zb_make_request_with_method_and_body(
+ char* url_path,
+ const char* method,
+ const char* body,
+ const char* content_type_header,
+ memory* response_data,
+ long* http_code,
+ OnErrorCallback error_callback
+) {
+ zb_note_http_request(method, url_path, body);
+
+ if (s_http_perform) {
+ return s_http_perform(response_data, http_code, NULL);
+ }
+
+ CURL* curl = curl_easy_init();
+ if (!curl) {
+ error_callback(parse_error("Failed to initialize libcurl"));
+ return 0;
+ }
+
+ curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method);
+ curl_easy_setopt(curl, CURLOPT_URL, url_path);
+
+ struct curl_slist* headers = NULL;
+ headers = curl_slist_append(headers, "Accept: application/json");
+ if (body && content_type_header) {
+ headers = curl_slist_append(headers, content_type_header);
+ }
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
+ if (body) {
+ curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, body);
+ }
+
+ set_write_callback(curl, response_data);
+
+ CURLcode res = curl_easy_perform(curl);
+ if (res != CURLE_OK) {
+ char error_message[300];
+ snprintf(
+ error_message, sizeof(error_message),
+ "Failed to perform request. CURLcode: %d. Error: %s",
+ res, curl_easy_strerror(res)
+ );
+ error_callback(parse_error(error_message));
+ curl_easy_cleanup(curl);
+ curl_slist_free_all(headers);
+ return 0;
+ }
+
+ *http_code = get_http_code(curl);
+ curl_easy_cleanup(curl);
+ curl_slist_free_all(headers);
+ return 1;
+}
+
int zb_make_request_with_post_body(
char* url_path,
char* body,
@@ -1359,6 +1483,9 @@ void validate_email_with_options(
if (!zb_check_validate_timeout(options->timeout, error_callback)) {
return;
}
+ if (!zb_check_callback_url(options, error_callback)) {
+ return;
+ }
if (options->use_post == 1) {
char *url_path = malloc(strlen(zb->api_base_url) + 16);
@@ -1436,6 +1563,12 @@ void validate_email_with_options(
exit(EXIT_FAILURE);
}
}
+ if (!zb_append_validate_extras(&post_body, options)) {
+ free(post_body);
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
zero_bounce_record_request_url_for_test(url_path);
@@ -1526,6 +1659,11 @@ void validate_email_with_options(
exit(EXIT_FAILURE);
}
}
+ if (!zb_append_validate_extras(&url_path, options)) {
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
free(api_key_esc);
free(email_esc);
diff --git a/test/OfflineApiTest.c b/test/OfflineApiTest.c
index 5ad9259..5f7017c 100644
--- a/test/OfflineApiTest.c
+++ b/test/OfflineApiTest.c
@@ -179,6 +179,11 @@ static int offline_perform(memory *response_data, long *http_code, char **conten
set_body(response_data,
"{\"type\":\"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1020/\","
"\"title\":\"Error 1020: Access denied\",\"status\":403,\"error_code\":1020,\"cloudflare_error\":true}");
+ } else if (strncmp(key, "ipt-detail", 10) == 0) {
+ /* The Inbox Placement Tester is a Django REST framework app and
+ reports errors under "detail". */
+ *http_code = 404;
+ set_body(response_data, "{\"detail\":\"Inbox placement test not found.\"}");
} else if (strncmp(key, "double-error-", 13) == 0) {
*http_code = 400;
set_body(response_data, "Error: Error: api_key is invalid");
@@ -710,6 +715,275 @@ void test_offline_priority3(void) {
TEST_ASSERT_NOT_NULL(strstr(request.query, "search=csv"));
}
+void test_offline_validate_new_options(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("validate-key");
+ ZBValidateOptions options = new_validate_options();
+
+ validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_NULL(strstr(request.query, "domain_info"));
+ TEST_ASSERT_NULL(strstr(request.query, "greylist_processing"));
+ TEST_ASSERT_NULL(strstr(request.query, "callback_url"));
+
+ reset_offline();
+ options.domain_info = 1;
+ options.greylist_processing = 1;
+ options.callback_url = "https://example.com/greylist";
+ validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error);
+ request = only_request();
+ /* Booleans go on the wire as the strings "true"/"false". */
+ TEST_ASSERT_NOT_NULL(strstr(request.query, "domain_info=true"));
+ TEST_ASSERT_NOT_NULL(strstr(request.query, "greylist_processing=true"));
+ TEST_ASSERT_NOT_NULL(strstr(request.query, "callback_url="));
+
+ reset_offline();
+ options.use_post = 1;
+ options.domain_info = 0;
+ validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("POST", request.method);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "domain_info=false"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "callback_url="));
+}
+
+void test_offline_callback_url_requires_greylist(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("validate-key");
+ ZBValidateOptions options = new_validate_options();
+
+ options.callback_url = "https://example.com/greylist";
+ validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "greylist_processing"));
+
+ reset_offline();
+ options.greylist_processing = 0;
+ validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "greylist_processing"));
+
+ reset_offline();
+ options.greylist_processing = 1;
+ options.callback_url = " ";
+ validate_email_with_options(zb, "a@example.com", "", &options, on_validate, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "callback_url"));
+
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+}
+
+void test_offline_s3_and_sftp_jobs(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("bulk-key");
+ ZBBulkFileJobOptions options = new_bulk_file_job_options();
+
+ options.file_name = "lists/leads.csv";
+ email_finder_s3_file(zb, &options, on_send, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("POST", request.method);
+ TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host);
+ /* The S3 and SFTP jobs live on the bulkapi root, WITHOUT /v2. */
+ TEST_ASSERT_EQUAL_STRING("/emailFinderS3file", request.path);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "file_name=lists%2Fleads.csv"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "has_header_row=true"));
+ /* Unset options stay off the request entirely. */
+ TEST_ASSERT_NULL(strstr(request.body, "remove_duplicates"));
+ TEST_ASSERT_NULL(strstr(request.body, "return_url"));
+
+ reset_offline();
+ options.has_header_row = false;
+ options.remove_duplicates = 1;
+ options.return_url = "https://example.com/done";
+ domain_search_s3_file(zb, &options, on_send, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/domainSearchS3file", request.path);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "has_header_row=false"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "remove_duplicates=true"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "return_url="));
+
+ /* file_name is optional for validateSFTPfile alone: without it the input
+ bucket from the account's settings is used. */
+ reset_offline();
+ options = new_bulk_file_job_options();
+ validate_sftp_file(zb, &options, on_send, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/validateSFTPfile", request.path);
+ TEST_ASSERT_NULL(strstr(request.body, "file_name"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "has_header_row=true"));
+
+ reset_offline();
+ options.file_name = "my_file.txt";
+ score_sftp_file(zb, &options, on_send, on_error);
+ TEST_ASSERT_EQUAL_STRING("/scoreSFTPfile", only_request().path);
+ reset_offline();
+ email_finder_sftp_file(zb, &options, on_send, on_error);
+ TEST_ASSERT_EQUAL_STRING("/emailFinderSFTPfile", only_request().path);
+ reset_offline();
+ domain_search_sftp_file(zb, &options, on_send, on_error);
+ TEST_ASSERT_EQUAL_STRING("/domainSearchSFTPfile", only_request().path);
+
+ /* exportSFTPfile takes export_path, not the export_bucket exportS3file uses. */
+ reset_offline();
+ export_sftp_file(zb, "fid", "/exports", on_send, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/exportSFTPfile", request.path);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "file_id=fid"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "export_path=%2Fexports"));
+ TEST_ASSERT_NULL(strstr(request.body, "export_bucket"));
+
+ reset_offline();
+ export_sftp_file(zb, "fid", NULL, on_send, on_error);
+ TEST_ASSERT_NULL(strstr(only_request().body, "export_path"));
+}
+
+void test_offline_bulk_jobs_reject_before_request(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("bulk-key");
+ ZBBulkFileJobOptions options = new_bulk_file_job_options();
+
+ email_finder_s3_file(zb, &options, on_send, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name"));
+
+ /* An unset name is allowed for validateSFTPfile, but a blank one is not. */
+ g_error_called = 0;
+ options.file_name = " ";
+ validate_sftp_file(zb, &options, on_send, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name"));
+
+ g_error_called = 0;
+ export_sftp_file(zb, " ", NULL, on_send, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_id"));
+
+ g_error_called = 0;
+ export_sftp_file(zb, "fid", " ", on_send, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "export_path"));
+
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+}
+
+void test_offline_list_evaluator_base_url(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("eval-key");
+
+ get_list_evaluator_result(zb, "a b/c", on_raw, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("bulkapi.zerobounce.net", request.host);
+ /* The file id is one path segment, so a slash in it must stay encoded and
+ a space must not become a plus. */
+ TEST_ASSERT_EQUAL_STRING("/v2/listevaluator/a%20b%2Fc/", request.path);
+
+ reset_offline();
+ get_list_evaluator_result(zb, " ", on_raw, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_id"));
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+
+ /* It is a separate host, not a path built from the bulk base. */
+ reset_offline();
+ zero_bounce_set_list_evaluator_base_url(zb, "https://bulk.example.test/v2/listevaluator");
+ get_list_evaluator_result(zb, "fid", on_raw, on_error);
+ TEST_ASSERT_EQUAL_STRING("bulk.example.test", only_request().host);
+ zero_bounce_set_list_evaluator_base_url(zb, "https://bulkapi.zerobounce.net/v2/listevaluator");
+}
+
+void test_offline_inbox_placement(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("ipt-key");
+
+ get_inbox_placement_providers(zb, on_raw, on_error);
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_STRING("/api/ipt/providers", request.path);
+ /* The API key goes on the query string here, not in the body. */
+ TEST_ASSERT_EQUAL_STRING("ipt-key", request.api_key);
+
+ reset_offline();
+ get_inbox_placement_test_types(zb, on_raw, on_error);
+ TEST_ASSERT_EQUAL_STRING("/api/ipt/test-types", only_request().path);
+
+ reset_offline();
+ ZBCreateInboxPlacementTestOptions create = new_create_inbox_placement_test_options();
+ create.test_type = 1;
+ create.sender_email = "news@example.com";
+ create.webhook_url = "https://example.com/ipt";
+ create_inbox_placement_test(zb, "Launch", &create, on_raw, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("POST", request.method);
+ TEST_ASSERT_EQUAL_STRING("/api/ipt/test", request.path);
+ /* Bodies are camelCase JSON, and unset fields are left out. */
+ TEST_ASSERT_EQUAL_STRING(
+ "{\"name\":\"Launch\",\"testType\":1,\"senderEmail\":\"news@example.com\","
+ "\"webhookUrl\":\"https://example.com/ipt\"}",
+ request.body
+ );
+
+ reset_offline();
+ create_inbox_placement_test(zb, "Launch", NULL, on_raw, on_error);
+ TEST_ASSERT_EQUAL_STRING("{\"name\":\"Launch\"}", only_request().body);
+
+ reset_offline();
+ ZBListInboxPlacementTestsOptions list = new_list_inbox_placement_tests_options();
+ list.status = "finished";
+ list.page = 1;
+ list.page_size = 25;
+ list_inbox_placement_tests(zb, &list, on_raw, on_error);
+ request = only_request();
+ TEST_ASSERT_NOT_NULL(strstr(request.query, "status=finished"));
+ TEST_ASSERT_NOT_NULL(strstr(request.query, "page=1"));
+ TEST_ASSERT_NOT_NULL(strstr(request.query, "pageSize=25"));
+
+ reset_offline();
+ list_inbox_placement_tests(zb, NULL, on_raw, on_error);
+ request = only_request();
+ TEST_ASSERT_NULL(strstr(request.query, "status="));
+ TEST_ASSERT_NULL(strstr(request.query, "page="));
+
+ reset_offline();
+ delete_inbox_placement_test(zb, "a b/c", on_raw, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("DELETE", request.method);
+ TEST_ASSERT_EQUAL_STRING("/api/ipt/test/a%20b%2Fc", request.path);
+
+ reset_offline();
+ set_inbox_placement_webhook(zb, "https://example.com/ipt", on_raw, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("PUT", request.method);
+ TEST_ASSERT_EQUAL_STRING("/api/ipt/webhook", request.path);
+ TEST_ASSERT_EQUAL_STRING("{\"webhookUrl\":\"https://example.com/ipt\"}", request.body);
+
+ reset_offline();
+ get_inbox_placement_webhook(zb, on_raw, on_error);
+ TEST_ASSERT_EQUAL_STRING("/api/ipt/webhook", only_request().path);
+
+ reset_offline();
+ get_inbox_placement_test(zb, " ", on_raw, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "test_id"));
+ g_error_called = 0;
+ create_inbox_placement_test(zb, " ", NULL, on_raw, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "name"));
+ g_error_called = 0;
+ set_inbox_placement_webhook(zb, "", on_raw, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "webhook_url"));
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+}
+
+void test_offline_inbox_placement_detail_error(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("ipt-detail");
+
+ get_inbox_placement_test(zb, "123", on_raw, on_error);
+
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Inbox placement test not found."));
+}
+
void test_offline_rejects_before_request(void) {
reset_offline();
ZeroBounce *zb = client_with("");
@@ -896,6 +1170,13 @@ void offline_run_tests(void) {
RUN_TEST(test_offline_activity_usage_score);
RUN_TEST(test_offline_finder_and_domain);
RUN_TEST(test_offline_priority3);
+ RUN_TEST(test_offline_validate_new_options);
+ RUN_TEST(test_offline_callback_url_requires_greylist);
+ RUN_TEST(test_offline_s3_and_sftp_jobs);
+ RUN_TEST(test_offline_bulk_jobs_reject_before_request);
+ RUN_TEST(test_offline_list_evaluator_base_url);
+ RUN_TEST(test_offline_inbox_placement);
+ RUN_TEST(test_offline_inbox_placement_detail_error);
RUN_TEST(test_offline_rejects_before_request);
RUN_TEST(test_offline_html_403);
RUN_TEST(test_offline_html_200_validate);
From 3773610b76e4bd5ef5ab00272331ac0028f1fa66 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Tue, 20 Oct 2026 22:35:45 +0400
Subject: [PATCH 11/19] 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 the matching strcmp arm in
sub_status_from_string, following role_based_catch_all.
Unknown values were already safe: sub_status_from_string ends in `return
None`, so a sub_status added after this SDK shipped maps to None rather than
leaving the field uninitialised. A test pins that alongside the two new ones.
Co-Authored-By: Claude Opus 5
---
include/ZeroBounce/ZBValidateResponse.h | 3 +-
src/ZBValidateRespone.c | 2 ++
test/ZeroBounceTest.c | 38 +++++++++++++++++++++++++
3 files changed, 42 insertions(+), 1 deletion(-)
diff --git a/include/ZeroBounce/ZBValidateResponse.h b/include/ZeroBounce/ZBValidateResponse.h
index bdb2b14..74f0b0c 100644
--- a/include/ZeroBounce/ZBValidateResponse.h
+++ b/include/ZeroBounce/ZBValidateResponse.h
@@ -55,7 +55,8 @@ typedef enum {
RoleBasedCatchAll,
Disposable,
Toxic,
- AcceptAll
+ AcceptAll,
+ AiAgentMailbox
} ZBValidateSubStatus;
/**
diff --git a/src/ZBValidateRespone.c b/src/ZBValidateRespone.c
index 15434fe..eafcabb 100644
--- a/src/ZBValidateRespone.c
+++ b/src/ZBValidateRespone.c
@@ -71,6 +71,8 @@ ZBValidateSubStatus sub_status_from_string(const char* string) {
return Toxic;
} else if (strcmp(string, "accept_all") == 0) {
return AcceptAll;
+ } else if (strcmp(string, "ai_agent_mailbox") == 0) {
+ return AiAgentMailbox;
}
return None;
diff --git a/test/ZeroBounceTest.c b/test/ZeroBounceTest.c
index 0418590..54d490a 100644
--- a/test/ZeroBounceTest.c
+++ b/test/ZeroBounceTest.c
@@ -347,6 +347,41 @@ void test_validate_email_invalid(void)
validate_email(zb, "valid@example.com", "127.0.0.1", on_success_validate_invalid, on_error_invalid);
}
+void test_sub_status_ai_agent_mailbox(void)
+{
+ // Returned under both valid and catch-all; the mapping is status-independent.
+ TEST_ASSERT_EQUAL_INT(AiAgentMailbox, sub_status_from_string("ai_agent_mailbox"));
+}
+
+void test_sub_status_ai_agent_mailbox_parsed_from_response(void)
+{
+ const char* valid_json =
+ "{\n"
+ " \"address\": \"agent@example.com\",\n"
+ " \"status\": \"valid\",\n"
+ " \"sub_status\": \"ai_agent_mailbox\"\n"
+ "}";
+ ZBValidateResponse parsed = zb_validate_response_from_json(json_tokener_parse(valid_json));
+ TEST_ASSERT_EQUAL_INT(AiAgentMailbox, parsed.sub_status);
+
+ const char* catch_all_json =
+ "{\n"
+ " \"address\": \"agent@example.com\",\n"
+ " \"status\": \"catch-all\",\n"
+ " \"sub_status\": \"ai_agent_mailbox\"\n"
+ "}";
+ ZBValidateResponse parsed_catch_all = zb_validate_response_from_json(json_tokener_parse(catch_all_json));
+ TEST_ASSERT_EQUAL_INT(AiAgentMailbox, parsed_catch_all.sub_status);
+}
+
+void test_sub_status_unknown_falls_back_to_none(void)
+{
+ // sub_status_from_string ends in `return None`, so a sub_status added to
+ // the API after this SDK shipped maps to None rather than reading past the
+ // end of the chain or leaving the field uninitialised.
+ TEST_ASSERT_EQUAL_INT(None, sub_status_from_string("some_future_sub_status"));
+}
+
void test_validate_email_valid(void)
{
response_json = "{\n"
@@ -1094,6 +1129,9 @@ int main(void)
{
UNITY_BEGIN();
+ RUN_TEST(test_sub_status_ai_agent_mailbox);
+ RUN_TEST(test_sub_status_ai_agent_mailbox_parsed_from_response);
+ RUN_TEST(test_sub_status_unknown_falls_back_to_none);
RUN_TEST(test_get_credits_invalid);
RUN_TEST(test_get_credits_valid);
RUN_TEST(test_get_api_usage_invalid);
From d6f10f4b3d9d333cc4f8bf1e1bcb27d86d612176 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sat, 24 Oct 2026 10:57:05 +0400
Subject: [PATCH 12/19] fix: correct validatebatch, the Inbox Placement Tester
and bulk file jobs against the live API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Verified against the live API; where it disagrees with the docs 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 `Authorization: Bearer`, not
the `api_key` query parameter the rest of the API takes, and its listing is
`GET /tests` (plural) — `/test` answers 'Method "GET" not allowed'.
zb_make_request_with_extra_header carries the one extra header, and the
offline stub reads the key from it the way the API does.
- Every S3/SFTP job requires both `file_name` and `return_url`, including
validateSFTPfile, whose file_name the docs call optional.
- send_evaluated_file takes the id of a finished evaluation as a form field,
not an upload, so its second argument is now a file id.
- A `"success": false` field on an HTTP 200 is a failure, not a result:
getfile and deletefile report an unknown or deleted file id that way.
ZBErrorResponse gained `status_code` so a caller can tell 429 from 500
without parsing the message.
Co-Authored-By: Claude Opus 5
---
include/ZeroBounce/ZBErrorResponse.h | 24 ++++++
include/ZeroBounce/ZeroBounce.h | 1 +
include/ZeroBounce/zb_internal.h | 17 ++++
src/Priority3.c | 110 ++++++++++++++++--------
src/ZBErrorResponse.c | 42 ++++++++-
src/ZeroBounce.c | 36 +++++++-
test/OfflineApiTest.c | 123 ++++++++++++++++++++++++---
7 files changed, 300 insertions(+), 53 deletions(-)
diff --git a/include/ZeroBounce/ZBErrorResponse.h b/include/ZeroBounce/ZBErrorResponse.h
index e316b3b..ac33d0c 100644
--- a/include/ZeroBounce/ZBErrorResponse.h
+++ b/include/ZeroBounce/ZBErrorResponse.h
@@ -21,6 +21,12 @@
typedef struct {
bool success;
StringVector errors;
+ /**
+ * The HTTP status the request came back with, or 0 when the error was
+ * raised before a response arrived (a rejected argument, a transport
+ * failure). Lets a caller tell 429 from 500 without parsing the message.
+ */
+ long status_code;
} ZBErrorResponse;
/**
@@ -46,6 +52,24 @@ char* zb_error_response_to_string(ZBErrorResponse* response);
*/
ZBErrorResponse parse_error(const char* error);
+/**
+ * @brief As parse_error, but records the HTTP status the response carried.
+ *
+ * @param error error string
+ * @param status_code HTTP status, or 0 when there was no response
+ * @return ZBErrorResponse new instance
+ */
+ZBErrorResponse parse_error_with_status(const char* error, long status_code);
+
+/**
+ * @brief Whether a JSON body says the call failed although the HTTP status was
+ * a success. The API sends "success" as a JSON false, "false" or "False".
+ *
+ * @param body response body
+ * @return true when the body is a failure
+ */
+bool zb_success_means_failure(const char* body);
+
/**
* @brief Function used to compare ZBErrorResponse instances.
*
diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h
index 5bc0a9b..6375939 100644
--- a/include/ZeroBounce/ZeroBounce.h
+++ b/include/ZeroBounce/ZeroBounce.h
@@ -1145,6 +1145,7 @@ const char* zero_bounce_get_last_post_body_for_test(void);
const char *zero_bounce_test_method(void);
const char *zero_bounce_test_url(void);
const char *zero_bounce_test_body(void);
+const char *zero_bounce_test_extra_header(void);
int zero_bounce_test_field_count(void);
const char *zero_bounce_test_field_name(int index);
const char *zero_bounce_test_field_value(int index);
diff --git a/include/ZeroBounce/zb_internal.h b/include/ZeroBounce/zb_internal.h
index 62b176f..ef1199a 100644
--- a/include/ZeroBounce/zb_internal.h
+++ b/include/ZeroBounce/zb_internal.h
@@ -28,6 +28,22 @@ int zb_make_request_with_method_and_body(
OnErrorCallback error_callback
);
+/*
+ * As zb_make_request_with_method_and_body, plus one extra request header.
+ * The Inbox Placement Tester needs Authorization: Bearer, and the Warmup API
+ * an API-Key header.
+ */
+int zb_make_request_with_extra_header(
+ char* url_path,
+ const char* method,
+ const char* body,
+ const char* content_type_header,
+ const char* extra_header,
+ memory* response_data,
+ long* http_code,
+ OnErrorCallback error_callback
+);
+
int zb_make_request_with_post_body(
char *url_path,
char *body,
@@ -38,6 +54,7 @@ int zb_make_request_with_post_body(
void zero_bounce_record_request_url_for_test(const char *url);
void zb_note_http_request(const char *method, const char *url, const char *body);
void zb_note_http_field(const char *name, const char *value);
+void zb_note_http_header(const char *header);
int zb_http_perform_if_mock(memory *response_data, long *http_code, char **content_type_out);
char *zb_message_from_body(const char *body, long status_code);
diff --git a/src/Priority3.c b/src/Priority3.c
index a1a5ff3..6c7d4af 100644
--- a/src/Priority3.c
+++ b/src/Priority3.c
@@ -711,13 +711,32 @@ void get_list_evaluator_result(
void send_evaluated_file(
ZeroBounce *zb,
- char *file_path,
+ char *file_id,
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
+ char *body = NULL;
+ size_t cap = 0;
char url[512];
+
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ if (p3_is_blank(file_id)) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: file_id"));
+ }
+ return;
+ }
+
+ /*
+ * LIVE: send-evaluated-file takes the id of a finished evaluation as a
+ * form field, not an upload. The trailing slash is part of the endpoint.
+ */
+ p3_append_form_field(&body, &cap, "api_key", zb->api_key);
+ p3_append_form_field(&body, &cap, "file_id", file_id);
snprintf(url, sizeof(url), "%s/send-evaluated-file/", zb->bulk_api_base_url);
- p3_bulk_root_multipart(zb, url, file_path, NULL, NULL, success_callback, error_callback);
+ p3_post_send_file_form(zb, url, body, success_callback, error_callback);
}
void add_filter(
@@ -1039,48 +1058,45 @@ static bool p3_is_blank(const char *value) {
* POSTs one of the six documented S3 / SFTP file jobs on the bulkapi root.
*
* Booleans go on the wire as the strings "true"/"false", and an option the
- * caller left unset is not sent at all. file_name_required is false only for
- * validateSFTPfile, where the docs make the name optional.
+ * 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 call its file_name optional.
+ * Without either the API answers "Empty parameter: file_name" /
+ * "Empty parameter: return_url", so both are checked before sending.
*/
static void p3_bulk_root_file_job(
ZeroBounce *zb,
const char *path,
const ZBBulkFileJobOptions *options,
- bool file_name_required,
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
char *body = NULL;
size_t cap = 0;
char url[512];
- bool blank;
if (zero_bounce_invalid_api_key(zb, error_callback)) {
return;
}
- if (!options) {
+ if (!options || p3_is_blank(options->file_name)) {
if (error_callback) {
error_callback(parse_error("Empty parameter: file_name"));
}
return;
}
- blank = options->file_name != NULL && p3_is_blank(options->file_name);
- if ((options->file_name == NULL && file_name_required) || blank) {
+ if (p3_is_blank(options->return_url)) {
if (error_callback) {
- error_callback(parse_error("Empty parameter: file_name"));
+ error_callback(parse_error("Empty parameter: return_url"));
}
return;
}
p3_append_form_field(&body, &cap, "api_key", zb->api_key);
p3_append_form_field(&body, &cap, "has_header_row", options->has_header_row ? "true" : "false");
- if (options->file_name) {
- p3_append_form_field(&body, &cap, "file_name", options->file_name);
- }
+ p3_append_form_field(&body, &cap, "file_name", options->file_name);
p3_append_optional_bool_form(&body, &cap, "remove_duplicates", options->remove_duplicates);
- if (options->return_url) {
- p3_append_form_field(&body, &cap, "return_url", options->return_url);
- }
+ p3_append_form_field(&body, &cap, "return_url", options->return_url);
snprintf(url, sizeof(url), "%s%s", zb->bulk_api_root_url, path);
p3_post_send_file_form(zb, url, body, success_callback, error_callback);
@@ -1133,7 +1149,7 @@ void email_finder_s3_file(
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
- p3_bulk_root_file_job(zb, "/emailFinderS3file", options, true, success_callback, error_callback);
+ p3_bulk_root_file_job(zb, "/emailFinderS3file", options, success_callback, error_callback);
}
void domain_search_s3_file(
@@ -1142,7 +1158,7 @@ void domain_search_s3_file(
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
- p3_bulk_root_file_job(zb, "/domainSearchS3file", options, true, success_callback, error_callback);
+ p3_bulk_root_file_job(zb, "/domainSearchS3file", options, success_callback, error_callback);
}
void validate_sftp_file(
@@ -1152,7 +1168,7 @@ void validate_sftp_file(
OnErrorCallback error_callback
) {
/* The one job whose file_name the docs make optional. */
- p3_bulk_root_file_job(zb, "/validateSFTPfile", options, false, success_callback, error_callback);
+ p3_bulk_root_file_job(zb, "/validateSFTPfile", options, success_callback, error_callback);
}
void score_sftp_file(
@@ -1161,7 +1177,7 @@ void score_sftp_file(
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
- p3_bulk_root_file_job(zb, "/scoreSFTPfile", options, true, success_callback, error_callback);
+ p3_bulk_root_file_job(zb, "/scoreSFTPfile", options, success_callback, error_callback);
}
void email_finder_sftp_file(
@@ -1170,7 +1186,7 @@ void email_finder_sftp_file(
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
- p3_bulk_root_file_job(zb, "/emailFinderSFTPfile", options, true, success_callback, error_callback);
+ p3_bulk_root_file_job(zb, "/emailFinderSFTPfile", options, success_callback, error_callback);
}
void domain_search_sftp_file(
@@ -1179,7 +1195,7 @@ void domain_search_sftp_file(
OnSuccessCallbackSendFile success_callback,
OnErrorCallback error_callback
) {
- p3_bulk_root_file_job(zb, "/domainSearchSFTPfile", options, true, success_callback, error_callback);
+ p3_bulk_root_file_job(zb, "/domainSearchSFTPfile", options, success_callback, error_callback);
}
void export_sftp_file(
@@ -1198,9 +1214,12 @@ void export_sftp_file(
/*
* One Inbox Placement Tester call.
*
- * Unlike the rest of the SDK this one puts the API key on the query string,
- * sends camelCase JSON bodies, and uses PUT and DELETE as well as GET and
- * POST, so it builds the request itself.
+ * Unlike the rest of the SDK this one authenticates with an Authorization:
+ * Bearer header, sends camelCase JSON bodies, and uses PUT and DELETE as well
+ * as GET and POST, so it builds the request itself.
+ *
+ * LIVE: sent as an api_key query parameter the request is rejected as
+ * unauthorized, and the key would end up in whatever logs the query string.
*/
static void p3_inbox_placement_request(
ZeroBounce *zb,
@@ -1211,9 +1230,11 @@ static void p3_inbox_placement_request(
OnSuccessCallbackRawJson success_callback,
OnErrorCallback error_callback
) {
- char *api_key_esc;
char *url_path;
+ char *auth_header;
int url_len;
+ int auth_len;
+ const char *query = extra_query ? extra_query : "";
memory response_data = {0};
long http_code = 0;
@@ -1221,26 +1242,38 @@ static void p3_inbox_placement_request(
return;
}
- api_key_esc = zb_url_escape(zb->api_key);
+ /* The caller builds the query with a leading '&'; without an api_key in
+ front of it, the first separator has to become a '?'. */
+ if (query[0] == '&') {
+ query++;
+ }
url_len = snprintf(
- NULL, 0, "%s%s?api_key=%s%s",
- zb->inbox_placement_base_url, path, api_key_esc, extra_query ? extra_query : ""
+ NULL, 0, "%s%s%s%s",
+ zb->inbox_placement_base_url, path, query[0] ? "?" : "", query
);
url_path = malloc((size_t)url_len + 1);
if (!url_path) {
- free(api_key_esc);
fprintf(stderr, "Memory allocation failed.\n");
exit(EXIT_FAILURE);
}
snprintf(
- url_path, (size_t)url_len + 1, "%s%s?api_key=%s%s",
- zb->inbox_placement_base_url, path, api_key_esc, extra_query ? extra_query : ""
+ url_path, (size_t)url_len + 1, "%s%s%s%s",
+ zb->inbox_placement_base_url, path, query[0] ? "?" : "", query
);
- free(api_key_esc);
- if (!zb_make_request_with_method_and_body(
- url_path, method, body, "Content-Type: application/json",
+ auth_len = snprintf(NULL, 0, "Authorization: Bearer %s", zb->api_key);
+ auth_header = malloc((size_t)auth_len + 1);
+ if (!auth_header) {
+ free(url_path);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ snprintf(auth_header, (size_t)auth_len + 1, "Authorization: Bearer %s", zb->api_key);
+
+ if (!zb_make_request_with_extra_header(
+ url_path, method, body, "Content-Type: application/json", auth_header,
&response_data, &http_code, error_callback)) {
+ free(auth_header);
free(response_data.response);
free(url_path);
return;
@@ -1254,6 +1287,7 @@ static void p3_inbox_placement_request(
} else {
p3_send_json_success(&response_data, http_code, success_callback, error_callback);
}
+ free(auth_header);
free(response_data.response);
free(url_path);
}
@@ -1413,7 +1447,11 @@ void list_inbox_placement_tests(
snprintf(query + used, sizeof(query) - used, "&pageSize=%d", options->page_size);
}
}
- p3_inbox_placement_request(zb, "GET", "/test", query, NULL, success_callback, error_callback);
+ /*
+ * LIVE: the listing is GET /tests (plural). /test is the single-test path
+ * and answers 'Method "GET" not allowed'.
+ */
+ p3_inbox_placement_request(zb, "GET", "/tests", query, NULL, success_callback, error_callback);
}
void delete_inbox_placement_test(
diff --git a/src/ZBErrorResponse.c b/src/ZBErrorResponse.c
index 9b52cbe..129f2aa 100644
--- a/src/ZBErrorResponse.c
+++ b/src/ZBErrorResponse.c
@@ -2,6 +2,7 @@
#include
#include
#include
+#include
#include "ZeroBounce/zb_internal.h"
@@ -10,6 +11,7 @@ ZBErrorResponse new_zb_error_response() {
response.success = false;
response.errors = string_vector_init();
+ response.status_code = 0;
return response;
}
@@ -34,7 +36,12 @@ char* zb_error_response_to_string(ZBErrorResponse* response) {
}
ZBErrorResponse parse_error(const char* error) {
+ return parse_error_with_status(error, 0);
+}
+
+ZBErrorResponse parse_error_with_status(const char* error, long status_code) {
ZBErrorResponse response = new_zb_error_response();
+ response.status_code = status_code;
if (!error || strlen(error) == 0) {
return response;
@@ -277,10 +284,35 @@ void zb_report_body_error(const char *body, long status_code, OnErrorCallback er
return;
}
message = zb_message_from_body(body, status_code);
- error_callback(parse_error(message));
+ error_callback(parse_error_with_status(message, status_code));
free(message);
}
+bool zb_success_means_failure(const char *body) {
+ const char *text = zb_skip_ws(body ? body : "");
+ json_object *parsed;
+ json_object *success;
+ bool failed = false;
+
+ if (text[0] != '{') {
+ return false;
+ }
+ parsed = json_tokener_parse(text);
+ if (!parsed) {
+ return false;
+ }
+ if (json_object_object_get_ex(parsed, "success", &success)) {
+ if (json_object_is_type(success, json_type_boolean)) {
+ failed = !json_object_get_boolean(success);
+ } else if (json_object_is_type(success, json_type_string)) {
+ const char *value = json_object_get_string(success);
+ failed = value && (strcasecmp(value, "false") == 0);
+ }
+ }
+ json_object_put(parsed);
+ return failed;
+}
+
bool zb_read_success_json(const char *body, long status_code, json_object **out, OnErrorCallback error_callback) {
const char *text = body ? body : "";
json_object *parsed;
@@ -288,7 +320,13 @@ bool zb_read_success_json(const char *body, long status_code, json_object **out,
return false;
}
*out = NULL;
- if (status_code > 299 || zb_body_looks_like_html(text) || zb_skip_ws(text)[0] == '\0') {
+ /*
+ * LIVE: a "success": false field on an HTTP 200 is a failure, not a
+ * result. getfile and deletefile report an unknown or deleted file id
+ * that way, and the caller should not have to inspect a flag to notice.
+ */
+ if (status_code > 299 || zb_body_looks_like_html(text) ||
+ zb_skip_ws(text)[0] == '\0' || zb_success_means_failure(text)) {
zb_report_body_error(text, status_code, error_callback);
return false;
}
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index 198c890..8aa9c84 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -65,6 +65,8 @@ typedef struct {
char value[1024];
} fields[ZB_TEST_FIELD_MAX];
int field_count;
+ /* The one extra header the request carried, "Name: value", or "". */
+ char extra_header[512];
} ZBTlsRequest;
static _Thread_local ZBTlsRequest zb_tls_request;
@@ -92,6 +94,10 @@ void zb_note_http_request(const char *method, const char *url, const char *body)
}
}
+void zb_note_http_header(const char *header) {
+ zb_copy_cap(zb_tls_request.extra_header, sizeof(zb_tls_request.extra_header), header ? header : "");
+}
+
void zb_note_http_field(const char *name, const char *value) {
int index;
if (zb_tls_request.field_count >= ZB_TEST_FIELD_MAX) {
@@ -105,6 +111,7 @@ void zb_note_http_field(const char *name, const char *value) {
const char *zero_bounce_test_method(void) { return zb_tls_request.method; }
const char *zero_bounce_test_url(void) { return zb_tls_request.url; }
const char *zero_bounce_test_body(void) { return zb_tls_request.body; }
+const char *zero_bounce_test_extra_header(void) { return zb_tls_request.extra_header; }
int zero_bounce_test_field_count(void) { return zb_tls_request.field_count; }
const char *zero_bounce_test_field_name(int index) {
if (index < 0 || index >= zb_tls_request.field_count) return "";
@@ -594,8 +601,25 @@ int zb_make_request_with_method_and_body(
memory* response_data,
long* http_code,
OnErrorCallback error_callback
+) {
+ return zb_make_request_with_extra_header(
+ url_path, method, body, content_type_header, NULL,
+ response_data, http_code, error_callback
+ );
+}
+
+int zb_make_request_with_extra_header(
+ char* url_path,
+ const char* method,
+ const char* body,
+ const char* content_type_header,
+ const char* extra_header,
+ memory* response_data,
+ long* http_code,
+ OnErrorCallback error_callback
) {
zb_note_http_request(method, url_path, body);
+ zb_note_http_header(extra_header);
if (s_http_perform) {
return s_http_perform(response_data, http_code, NULL);
@@ -615,6 +639,9 @@ int zb_make_request_with_method_and_body(
if (body && content_type_header) {
headers = curl_slist_append(headers, content_type_header);
}
+ if (extra_header) {
+ headers = curl_slist_append(headers, extra_header);
+ }
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
if (body) {
curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, body);
@@ -1758,7 +1785,14 @@ void validate_email_batch_with_options(
json_object_object_add(payload, "api_key", json_object_new_string(zb->api_key));
json_object_object_add(payload, "email_batch", email_batch_json);
if (options->timeout >= 0) {
- json_object_object_add(payload, "timeout", json_object_new_int(options->timeout));
+ /*
+ * 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".
+ */
+ char timeout_text[16];
+ snprintf(timeout_text, sizeof(timeout_text), "%d", options->timeout);
+ json_object_object_add(payload, "timeout", json_object_new_string(timeout_text));
}
if (options->activity_data >= 0) {
json_object_object_add(
diff --git a/test/OfflineApiTest.c b/test/OfflineApiTest.c
index 5f7017c..e461013 100644
--- a/test/OfflineApiTest.c
+++ b/test/OfflineApiTest.c
@@ -16,6 +16,8 @@ typedef struct {
char api_key[256];
char body[8192];
char query[4096];
+ /* The one extra header the request carried, "Name: value", or "". */
+ char extra_header[512];
} OfflineRequest;
static OfflineRequest g_requests[OFFLINE_MAX];
@@ -23,6 +25,7 @@ static int g_count;
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
static char g_error_text[1024];
static int g_error_called;
+static long g_error_status;
static int g_success_called;
static char g_echo[256];
static _Thread_local char tls_echo[256];
@@ -154,6 +157,22 @@ static int offline_perform(memory *response_data, long *http_code, char **conten
if (!key[0]) {
field_value("api_key", key, sizeof(key));
}
+ snprintf(request.extra_header, sizeof(request.extra_header), "%s",
+ zero_bounce_test_extra_header() ? zero_bounce_test_extra_header() : "");
+ if (!key[0]) {
+ /* The Inbox Placement Tester and the Warmup API carry the key in a
+ header instead of a parameter. */
+ const char *header = request.extra_header;
+ const char *value = NULL;
+ if (strncmp(header, "Authorization: Bearer ", 22) == 0) {
+ value = header + 22;
+ } else if (strncmp(header, "API-Key: ", 9) == 0) {
+ value = header + 9;
+ }
+ if (value) {
+ snprintf(key, sizeof(key), "%s", value);
+ }
+ }
snprintf(request.api_key, sizeof(request.api_key), "%s", key);
remember(&request);
@@ -174,6 +193,11 @@ static int offline_perform(memory *response_data, long *http_code, char **conten
junk[5014] = '\0';
set_body(response_data, junk);
free(junk);
+ } else if (strncmp(key, "success-false-str", 17) == 0) {
+ /* The API also sends "success" as the string "false". */
+ set_body(response_data, "{\"success\":\"False\",\"message\":\"File cannot be found.\"}");
+ } else if (strncmp(key, "success-false", 13) == 0) {
+ set_body(response_data, "{\"success\":false,\"message\":\"File cannot be found.\"}");
} else if (strncmp(key, "cf-json-", 8) == 0) {
*http_code = 403;
set_body(response_data,
@@ -263,6 +287,7 @@ static ZeroBounce *client_with(const char *key) {
static void on_error(ZBErrorResponse error) {
size_t used = 0;
g_error_called = 1;
+ g_error_status = error.status_code;
g_error_text[0] = '\0';
for (size_t i = 0; i < error.errors.size; i++) {
const char *part = error.errors.data[i] ? error.errors.data[i] : "";
@@ -659,8 +684,13 @@ void test_offline_priority3(void) {
TEST_ASSERT_NOT_NULL(strstr(g_raw, "p3-key"));
TEST_ASSERT_EQUAL_STRING("/v2/listevaluator/fid/", only_request().path);
reset_offline();
- send_evaluated_file(zb, "email_file.csv", on_send, on_error);
- TEST_ASSERT_EQUAL_STRING("/v2/send-evaluated-file/", only_request().path);
+ /* LIVE: send-evaluated-file takes the file id as a form field, not an
+ upload, and the trailing slash is part of the endpoint. */
+ send_evaluated_file(zb, "fid-1", on_send, on_error);
+ request = only_request();
+ TEST_ASSERT_EQUAL_STRING("POST", request.method);
+ TEST_ASSERT_EQUAL_STRING("/v2/send-evaluated-file/", request.path);
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "file_id=fid-1"));
reset_offline();
add_filter(zb, "exclude", "domain", "example.com", on_raw, on_error);
request = only_request();
@@ -779,6 +809,7 @@ void test_offline_s3_and_sftp_jobs(void) {
ZBBulkFileJobOptions options = new_bulk_file_job_options();
options.file_name = "lists/leads.csv";
+ options.return_url = "https://example.com/done";
email_finder_s3_file(zb, &options, on_send, on_error);
OfflineRequest request = only_request();
TEST_ASSERT_EQUAL_STRING("POST", request.method);
@@ -786,34 +817,46 @@ void test_offline_s3_and_sftp_jobs(void) {
/* The S3 and SFTP jobs live on the bulkapi root, WITHOUT /v2. */
TEST_ASSERT_EQUAL_STRING("/emailFinderS3file", request.path);
TEST_ASSERT_NOT_NULL(strstr(request.body, "file_name=lists%2Fleads.csv"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "return_url="));
TEST_ASSERT_NOT_NULL(strstr(request.body, "has_header_row=true"));
- /* Unset options stay off the request entirely. */
+ /* Options that were never set stay off the request entirely. */
TEST_ASSERT_NULL(strstr(request.body, "remove_duplicates"));
- TEST_ASSERT_NULL(strstr(request.body, "return_url"));
reset_offline();
options.has_header_row = false;
options.remove_duplicates = 1;
- options.return_url = "https://example.com/done";
domain_search_s3_file(zb, &options, on_send, on_error);
request = only_request();
TEST_ASSERT_EQUAL_STRING("/domainSearchS3file", request.path);
TEST_ASSERT_NOT_NULL(strstr(request.body, "has_header_row=false"));
TEST_ASSERT_NOT_NULL(strstr(request.body, "remove_duplicates=true"));
- TEST_ASSERT_NOT_NULL(strstr(request.body, "return_url="));
- /* file_name is optional for validateSFTPfile alone: without it the input
- bucket from the account's settings is used. */
+ /* LIVE: the docs call file_name optional for validateSFTPfile, but the
+ API answers "Empty parameter: file_name" without it, and every job
+ needs a return_url as well. Both are checked before sending. */
+ reset_offline();
+ options = new_bulk_file_job_options();
+ options.return_url = "https://example.com/done";
+ validate_sftp_file(zb, &options, on_send, on_error);
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name"));
+
reset_offline();
options = new_bulk_file_job_options();
+ options.file_name = "my_file.txt";
+ validate_sftp_file(zb, &options, on_send, on_error);
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "return_url"));
+
+ reset_offline();
+ options.return_url = "https://example.com/done";
validate_sftp_file(zb, &options, on_send, on_error);
request = only_request();
TEST_ASSERT_EQUAL_STRING("/validateSFTPfile", request.path);
- TEST_ASSERT_NULL(strstr(request.body, "file_name"));
- TEST_ASSERT_NOT_NULL(strstr(request.body, "has_header_row=true"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "file_name=my_file.txt"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "return_url="));
reset_offline();
- options.file_name = "my_file.txt";
score_sftp_file(zb, &options, on_send, on_error);
TEST_ASSERT_EQUAL_STRING("/scoreSFTPfile", only_request().path);
reset_offline();
@@ -846,13 +889,18 @@ void test_offline_bulk_jobs_reject_before_request(void) {
TEST_ASSERT_TRUE(g_error_called);
TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name"));
- /* An unset name is allowed for validateSFTPfile, but a blank one is not. */
+ /* A blank name is rejected just like a missing one. */
g_error_called = 0;
options.file_name = " ";
validate_sftp_file(zb, &options, on_send, on_error);
TEST_ASSERT_TRUE(g_error_called);
TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_name"));
+ g_error_called = 0;
+ send_evaluated_file(zb, " ", on_send, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_id"));
+
g_error_called = 0;
export_sftp_file(zb, " ", NULL, on_send, on_error);
TEST_ASSERT_TRUE(g_error_called);
@@ -898,8 +946,12 @@ void test_offline_inbox_placement(void) {
get_inbox_placement_providers(zb, on_raw, on_error);
OfflineRequest request = only_request();
TEST_ASSERT_EQUAL_STRING("/api/ipt/providers", request.path);
- /* The API key goes on the query string here, not in the body. */
- TEST_ASSERT_EQUAL_STRING("ipt-key", request.api_key);
+ /* LIVE: the key travels in an Authorization: Bearer header. Sent as an
+ api_key query parameter the request is rejected as unauthorized, and
+ the key would end up in whatever logs the query string. */
+ TEST_ASSERT_EQUAL_STRING("Authorization: Bearer ipt-key", request.extra_header);
+ TEST_ASSERT_NULL(strstr(request.query, "api_key="));
+ TEST_ASSERT_NULL(strstr(request.body, "api_key="));
reset_offline();
get_inbox_placement_test_types(zb, on_raw, on_error);
@@ -932,6 +984,9 @@ void test_offline_inbox_placement(void) {
list.page_size = 25;
list_inbox_placement_tests(zb, &list, on_raw, on_error);
request = only_request();
+ /* LIVE: the listing is GET /tests (plural). /test is the single-test path
+ and answers 'Method "GET" not allowed'. */
+ TEST_ASSERT_EQUAL_STRING("/api/ipt/tests", request.path);
TEST_ASSERT_NOT_NULL(strstr(request.query, "status=finished"));
TEST_ASSERT_NOT_NULL(strstr(request.query, "page=1"));
TEST_ASSERT_NOT_NULL(strstr(request.query, "pageSize=25"));
@@ -984,6 +1039,44 @@ void test_offline_inbox_placement_detail_error(void) {
TEST_ASSERT_NOT_NULL(strstr(g_error_text, "Inbox placement test not found."));
}
+void test_offline_batch_timeout_is_a_string(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("batch-timeout");
+ EmailToValidateVector emails = email_to_validate_vector_init();
+ ZBEmailToValidate a = {"a@example.com", ""};
+ ZBValidateOptions options = new_validate_options();
+
+ email_to_validate_vector_append(&emails, a);
+ options.timeout = 30;
+ validate_email_batch_with_options(zb, emails, &options, on_batch, on_error);
+
+ /* 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". */
+ TEST_ASSERT_NOT_NULL(strstr(only_request().body, "\"timeout\": \"30\""));
+ TEST_ASSERT_NULL(strstr(only_request().body, "\"timeout\": 30"));
+ email_to_validate_vector_free(&emails);
+}
+
+void test_offline_success_false_on_200_is_an_error(void) {
+ reset_offline();
+ /* LIVE: getfile and deletefile report an unknown or deleted file id with
+ HTTP 200 and a "success": false field. That is a failure, not a result,
+ and the status is kept on the error. */
+ ZeroBounce *zb = client_with("success-false");
+
+ delete_file(zb, "fid", on_delete, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "File cannot be found"));
+ TEST_ASSERT_EQUAL_INT(200, (int)g_error_status);
+
+ g_error_called = 0;
+ reset_offline();
+ zb = client_with("success-false-str");
+ delete_file(zb, "fid", on_delete, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+}
+
void test_offline_rejects_before_request(void) {
reset_offline();
ZeroBounce *zb = client_with("");
@@ -1174,6 +1267,8 @@ void offline_run_tests(void) {
RUN_TEST(test_offline_callback_url_requires_greylist);
RUN_TEST(test_offline_s3_and_sftp_jobs);
RUN_TEST(test_offline_bulk_jobs_reject_before_request);
+ RUN_TEST(test_offline_batch_timeout_is_a_string);
+ RUN_TEST(test_offline_success_false_on_200_is_an_error);
RUN_TEST(test_offline_list_evaluator_base_url);
RUN_TEST(test_offline_inbox_placement);
RUN_TEST(test_offline_inbox_placement_detail_error);
From 642492c29a54f0cc440d7b426f033ee1de14100c Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sat, 24 Oct 2026 11:01:13 +0400
Subject: [PATCH 13/19] feat: harden the HTTP layer against leaked keys and
stray hosts
- zb_api_key_is_clean rejects a key with surrounding whitespace or control
characters, which would otherwise be quoted unredacted in a transport error.
Both initializers run it.
- zb_check_request_url refuses anything that is not https, so the api_key is
never put on the wire in the clear, and every transport calls it before the
mock short-circuit so the offline suite sees the same rule.
- Redirects are never followed (CURLOPT_FOLLOWLOCATION 0, and only the https
protocol is enabled) and a 3xx is reported as a redirect rather than parsed.
- p3_escape_path_segment rejects a '.' or '..' id, which would walk the
request out of the endpoint it was meant for. The list evaluator file id and
the Inbox Placement test id go through it.
- zb_redact_api_key strips the key out of any message built from a URL.
- A regional base URL paired with the single global bulk host warns once:
ZeroBounce documents no regional variant of bulkapi.zerobounce.net.
Co-Authored-By: Claude Opus 5
---
include/ZeroBounce/zb_internal.h | 9 +++
src/Priority3.c | 26 ++++++-
src/ZBErrorResponse.c | 40 +++++++++-
src/ZeroBounce.c | 123 +++++++++++++++++++++++++++++++
test/OfflineApiTest.c | 63 ++++++++++++++++
5 files changed, 257 insertions(+), 4 deletions(-)
diff --git a/include/ZeroBounce/zb_internal.h b/include/ZeroBounce/zb_internal.h
index ef1199a..5462341 100644
--- a/include/ZeroBounce/zb_internal.h
+++ b/include/ZeroBounce/zb_internal.h
@@ -4,6 +4,15 @@
#include "ZeroBounce/ZeroBounce.h"
bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback);
+/* Whether a key is free of surrounding whitespace and control characters. */
+bool zb_api_key_is_clean(const char *api_key);
+/*
+ * Refuses a URL that is not https, and warns once when a regional base URL is
+ * paired with the single global bulk host.
+ */
+bool zb_check_request_url(const char *url, OnErrorCallback error_callback);
+/* Replaces the value of any api_key parameter in a message with REDACTED. */
+char *zb_redact_api_key(const char *text);
char *zb_url_escape(const char *value);
int zb_make_request(
char *url_path,
diff --git a/src/Priority3.c b/src/Priority3.c
index 6c7d4af..c77ec1c 100644
--- a/src/Priority3.c
+++ b/src/Priority3.c
@@ -255,6 +255,9 @@ static void p3_bulk_root_multipart(
long http_code = 0;
zb_note_http_request("POST", url_path, NULL);
+ if (!zb_check_request_url(url_path, error_callback)) {
+ goto cleanup;
+ }
zb_note_http_field("api_key", zb->api_key ? zb->api_key : "");
zb_note_http_field("file", "");
if (add_fields) {
@@ -698,9 +701,15 @@ void get_list_evaluator_result(
return;
}
- api_key_esc = zb_url_escape(zb->api_key);
/* The file id is one path segment, so a slash in it must stay encoded. */
file_id_esc = p3_escape_path_segment(file_id);
+ if (!file_id_esc) {
+ if (error_callback) {
+ error_callback(parse_error("Invalid parameter: file_id"));
+ }
+ return;
+ }
+ api_key_esc = zb_url_escape(zb->api_key);
url_path_len = snprintf(NULL, 0, url_pattern, zb->list_evaluator_base_url, file_id_esc, api_key_esc);
url_path = malloc((size_t)url_path_len + 1);
snprintf(url_path, (size_t)url_path_len + 1, url_pattern, zb->list_evaluator_base_url, file_id_esc, api_key_esc);
@@ -1299,7 +1308,13 @@ static void p3_inbox_placement_request(
* literal plus inside a path, so each one becomes %20 here.
*/
static char *p3_escape_path_segment(const char *value) {
- char *escaped = zb_url_escape(value);
+ char *escaped;
+ /* Everything else, slashes included, is percent-encoded and stays inside
+ the segment; "." and ".." would walk the request elsewhere. */
+ if (value && (strcmp(value, ".") == 0 || strcmp(value, "..") == 0)) {
+ return NULL;
+ }
+ escaped = zb_url_escape(value);
size_t len = strlen(escaped);
char *out = malloc(len * 3 + 1);
size_t i;
@@ -1325,13 +1340,18 @@ static char *p3_escape_path_segment(const char *value) {
}
static char *p3_inbox_placement_test_segment(const char *test_id, OnErrorCallback error_callback) {
+ char *segment;
if (p3_is_blank(test_id)) {
if (error_callback) {
error_callback(parse_error("Empty parameter: test_id"));
}
return NULL;
}
- return p3_escape_path_segment(test_id);
+ segment = p3_escape_path_segment(test_id);
+ if (!segment && error_callback) {
+ error_callback(parse_error("Invalid parameter: test_id"));
+ }
+ return segment;
}
static void p3_json_append(char **body, size_t *cap, const char *fragment) {
diff --git a/src/ZBErrorResponse.c b/src/ZBErrorResponse.c
index 129f2aa..17aec7c 100644
--- a/src/ZBErrorResponse.c
+++ b/src/ZBErrorResponse.c
@@ -278,13 +278,51 @@ char *zb_message_from_body(const char *body, long status_code) {
}
}
+char *zb_redact_api_key(const char *text) {
+ const char *src = text ? text : "";
+ size_t len = strlen(src);
+ char *out = malloc(len + 16);
+ size_t i = 0;
+ size_t j = 0;
+
+ if (!out) {
+ return NULL;
+ }
+ while (i < len) {
+ if (strncmp(src + i, "api_key=", 8) == 0) {
+ memcpy(out + j, "api_key=REDACTED", 16);
+ j += 16;
+ i += 8;
+ /* Skip the value, up to the next separator. */
+ while (i < len && src[i] != '&' && src[i] != ' ' && src[i] != '"') {
+ i++;
+ }
+ continue;
+ }
+ out[j++] = src[i++];
+ }
+ out[j] = '\0';
+ return out;
+}
+
void zb_report_body_error(const char *body, long status_code, OnErrorCallback error_callback) {
char *message;
+ char *safe;
if (!error_callback) {
return;
}
+ if (status_code >= 300 && status_code < 400) {
+ /* FOLLOWLOCATION is off: a redirect could send the api_key to another
+ host, so it is reported rather than followed. */
+ error_callback(parse_error_with_status(
+ "ZeroBounce returned a redirect, which this client does not follow.", status_code
+ ));
+ return;
+ }
message = zb_message_from_body(body, status_code);
- error_callback(parse_error_with_status(message, status_code));
+ safe = zb_redact_api_key(message);
+ error_callback(parse_error_with_status(safe ? safe : message, status_code));
+ free(safe);
free(message);
}
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index 8aa9c84..f39b306 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -71,6 +71,14 @@ typedef struct {
static _Thread_local ZBTlsRequest zb_tls_request;
+/*
+ * The region of the client making the current call, for the one-time bulk-host
+ * warning. Recorded at the guard every public call already runs, so the
+ * transports do not have to carry the client around.
+ */
+static _Thread_local const char *zb_tls_region = NULL;
+
+
static void zb_copy_cap(char *dest, size_t dest_len, const char *src) {
if (!dest || dest_len == 0) {
return;
@@ -197,6 +205,14 @@ void set_write_callback(CURL* curl, memory* response_data) {
curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void*)response_data);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 120L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
+ /* A redirect could send the api_key to another host, so one is reported
+ rather than followed, and only https is ever dialled. */
+ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
+#ifdef CURLOPT_PROTOCOLS_STR
+ curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
+#else
+ curl_easy_setopt(curl, CURLOPT_PROTOCOLS, (long)CURLPROTO_HTTPS);
+#endif
}
long get_http_code(CURL* curl) {
@@ -564,7 +580,36 @@ ZeroBounce* zero_bounce_get_instance() {
}
+/*
+ * A key with surrounding whitespace or control characters would otherwise be
+ * quoted, unredacted, in a transport error, so it is refused here. An empty
+ * key stays deferred to zero_bounce_invalid_api_key, which names the call
+ * that initializes the client.
+ */
+bool zb_api_key_is_clean(const char *api_key) {
+ const char *p;
+ if (!api_key || !api_key[0]) {
+ return true;
+ }
+ if (isspace((unsigned char)api_key[0]) ||
+ isspace((unsigned char)api_key[strlen(api_key) - 1])) {
+ return false;
+ }
+ for (p = api_key; *p; p++) {
+ if ((unsigned char)*p < 32 || (unsigned char)*p == 127) {
+ return false;
+ }
+ }
+ return true;
+}
+
void zero_bounce_initialize(ZeroBounce* zb, const char* api_key) {
+ if (!zb_api_key_is_clean(api_key)) {
+ fprintf(stderr,
+ "ZeroBounce: api_key has surrounding whitespace or control characters; "
+ "it was not accepted.\n");
+ return;
+ }
if (zb->api_key) {
free(zb->api_key);
}
@@ -573,6 +618,12 @@ void zero_bounce_initialize(ZeroBounce* zb, const char* api_key) {
void zero_bounce_initialize_with_base_url(ZeroBounce* zb, const char* api_key, ZBApiURL api_base_url) {
+ if (!zb_api_key_is_clean(api_key)) {
+ fprintf(stderr,
+ "ZeroBounce: api_key has surrounding whitespace or control characters; "
+ "it was not accepted.\n");
+ return;
+ }
if (zb->api_key) {
free(zb->api_key);
}
@@ -580,8 +631,58 @@ void zero_bounce_initialize_with_base_url(ZeroBounce* zb, const char* api_key, Z
zb->api_base_url = base_url_string_from_zb_api_url(api_base_url);
}
+/* The host part of a URL, lowercased, or "" when there is none. */
+static void zb_host_of(const char *url, char *out, size_t out_len) {
+ const char *start = url ? strstr(url, "://") : NULL;
+ const char *host = start ? start + 3 : (url ? url : "");
+ const char *end = host;
+ size_t i = 0;
+ while (*end && *end != '/' && *end != '?' && *end != '#') {
+ end++;
+ }
+ while (host + i < end && i + 1 < out_len) {
+ out[i] = (char)tolower((unsigned char)host[i]);
+ i++;
+ }
+ out[i] = '\0';
+}
+
+bool zb_check_request_url(const char *url, OnErrorCallback error_callback) {
+ static int warned_about_region = 0;
+ char host[256];
+
+ if (!url || strncmp(url, "https://", 8) != 0) {
+ if (error_callback) {
+ error_callback(parse_error("url must be an https:// URL"));
+ }
+ return false;
+ }
+ zb_host_of(url, host, sizeof(host));
+ if (!warned_about_region && zb_tls_region &&
+ strcmp(host, "bulkapi.zerobounce.net") == 0) {
+ warned_about_region = 1;
+ fprintf(stderr,
+ "ZeroBounce warning: the base URL is the %s API, but this request goes to "
+ "bulkapi.zerobounce.net. ZeroBounce documents no regional host for bulk files, "
+ "scoring files, email finder and domain search files, the list evaluator, "
+ "S3/SFTP or the Inbox Placement Tester. Set the matching base URLs with "
+ "zero_bounce_set_bulk_api_base_url and friends if ZeroBounce gave you "
+ "regional ones.\n",
+ zb_tls_region);
+ }
+ return true;
+}
+
bool zero_bounce_invalid_api_key(ZeroBounce *zb, OnErrorCallback error_callback) {
+ zb_tls_region = NULL;
+ if (zb && zb->api_base_url) {
+ if (strstr(zb->api_base_url, "api-eu.zerobounce.net")) {
+ zb_tls_region = "EU";
+ } else if (strstr(zb->api_base_url, "api-us.zerobounce.net")) {
+ zb_tls_region = "US";
+ }
+ }
if (zb->api_key == NULL || strlen(zb->api_key) == 0) {
ZBErrorResponse error_response = parse_error(
"ZeroBounce is not initialized. Please call zero_bounce_initialize(zb_instance, api_key); first"
@@ -621,6 +722,10 @@ int zb_make_request_with_extra_header(
zb_note_http_request(method, url_path, body);
zb_note_http_header(extra_header);
+ if (!zb_check_request_url(url_path, error_callback)) {
+ return 0;
+ }
+
if (s_http_perform) {
return s_http_perform(response_data, http_code, NULL);
}
@@ -678,6 +783,10 @@ int zb_make_request_with_post_body(
) {
zb_note_http_request("POST", url_path, body ? body : "");
+ if (!zb_check_request_url(url_path, error_callback)) {
+ return 0;
+ }
+
if (s_http_perform) {
return s_http_perform(response_data, http_code, NULL);
}
@@ -733,6 +842,10 @@ int zb_make_request(
OnErrorCallback error_callback
) {
zb_note_http_request(request_type ? request_type : "GET", url_path, NULL);
+
+ if (!zb_check_request_url(url_path, error_callback)) {
+ return 0;
+ }
if (s_http_perform) {
return s_http_perform(response_data, http_code, NULL);
}
@@ -1085,6 +1198,10 @@ static void get_file_internal(
zb_note_http_request("GET", url_path, NULL);
+ if (!zb_check_request_url(url_path, error_callback)) {
+ goto cleanup;
+ }
+
if (s_http_perform) {
if (!s_http_perform(&response_data, &http_code, &content_type)) {
if (error_callback) error_callback(parse_error("Mock request failed"));
@@ -1815,6 +1932,12 @@ void validate_email_batch_with_options(
memory response_data = {0};
long http_code = 0;
+ if (!zb_check_request_url(url_path, error_callback)) {
+ json_object_put(payload);
+ free(url_path);
+ return;
+ }
+
if (s_http_perform) {
json_object_put(payload);
if (!s_http_perform(&response_data, &http_code, NULL)) {
diff --git a/test/OfflineApiTest.c b/test/OfflineApiTest.c
index e461013..83ed41b 100644
--- a/test/OfflineApiTest.c
+++ b/test/OfflineApiTest.c
@@ -6,6 +6,7 @@
#include "unity.h"
#include "ZeroBounce/ZeroBounce.h"
+#include "ZeroBounce/zb_internal.h"
#define OFFLINE_MAX 64
@@ -198,6 +199,9 @@ static int offline_perform(memory *response_data, long *http_code, char **conten
set_body(response_data, "{\"success\":\"False\",\"message\":\"File cannot be found.\"}");
} else if (strncmp(key, "success-false", 13) == 0) {
set_body(response_data, "{\"success\":false,\"message\":\"File cannot be found.\"}");
+ } else if (strncmp(key, "redirect-302", 12) == 0) {
+ *http_code = 302;
+ set_body(response_data, "");
} else if (strncmp(key, "cf-json-", 8) == 0) {
*http_code = 403;
set_body(response_data,
@@ -1077,6 +1081,63 @@ void test_offline_success_false_on_200_is_an_error(void) {
TEST_ASSERT_TRUE(g_error_called);
}
+void test_offline_safety_rails(void) {
+ reset_offline();
+ ZeroBounce *zb = client_with("rails-key");
+
+ /* Only https is ever dialled: a plain-http base URL would put the key on
+ the wire in the clear. */
+ zb->api_base_url = "http://api.zerobounce.net/v2";
+ get_credits(zb, on_credits, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "https"));
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+
+ /* A path segment of "." or ".." would walk the request out of the
+ endpoint it was meant for. */
+ reset_offline();
+ zb = client_with("rails-key");
+ get_inbox_placement_test(zb, "..", on_raw, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "test_id"));
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+
+ reset_offline();
+ get_list_evaluator_result(zb, ".", on_raw, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "file_id"));
+ TEST_ASSERT_EQUAL_INT(0, g_count);
+
+ /* A key with surrounding whitespace or control characters would be
+ quoted, unredacted, in a transport error. */
+ TEST_ASSERT_TRUE(zb_api_key_is_clean("abc123"));
+ TEST_ASSERT_FALSE(zb_api_key_is_clean(" abc123"));
+ TEST_ASSERT_FALSE(zb_api_key_is_clean("abc123\n"));
+ TEST_ASSERT_FALSE(zb_api_key_is_clean("abc\t123"));
+
+ /* An api_key never reaches an error message. */
+ {
+ char *safe = zb_redact_api_key("GET https://api.zerobounce.net/v2/validate?api_key=sekrit&email=a");
+ TEST_ASSERT_NOT_NULL(safe);
+ TEST_ASSERT_NULL(strstr(safe, "sekrit"));
+ TEST_ASSERT_NOT_NULL(strstr(safe, "api_key=REDACTED"));
+ TEST_ASSERT_NOT_NULL(strstr(safe, "&email=a"));
+ free(safe);
+ }
+}
+
+void test_offline_redirect_is_not_followed(void) {
+ reset_offline();
+ /* FOLLOWLOCATION is off: a redirect could send the api_key to another
+ host, so it is reported rather than followed. */
+ ZeroBounce *zb = client_with("redirect-302");
+
+ get_credits(zb, on_credits, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "redirect"));
+ TEST_ASSERT_EQUAL_INT(302, (int)g_error_status);
+}
+
void test_offline_rejects_before_request(void) {
reset_offline();
ZeroBounce *zb = client_with("");
@@ -1269,6 +1330,8 @@ void offline_run_tests(void) {
RUN_TEST(test_offline_bulk_jobs_reject_before_request);
RUN_TEST(test_offline_batch_timeout_is_a_string);
RUN_TEST(test_offline_success_false_on_200_is_an_error);
+ RUN_TEST(test_offline_safety_rails);
+ RUN_TEST(test_offline_redirect_is_not_followed);
RUN_TEST(test_offline_list_evaluator_base_url);
RUN_TEST(test_offline_inbox_placement);
RUN_TEST(test_offline_inbox_placement_detail_error);
From 6e9ba657557143b028f994c2eb45c9f2ae0cea8a Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sat, 24 Oct 2026 11:06:30 +0400
Subject: [PATCH 14/19] feat: add a client for the Warmup API
The Warmup API is a separate product from email validation: its own host, its
own team-scoped key, and an API-Key header instead of an api_key parameter.
ZeroBounceWarmup takes the Warmup key; ZeroBounce stays for the validation
key, and the two never mix.
All 26 documented endpoints, each handing the raw JSON body to its callback,
or an empty string for the documented empty successes (HTTP 204 and an empty
200). The two CSV endpoints hand over the text and write it to a path when
one is given.
Arguments are checked before anything is sent: the engagement ranges
(standard rules 0-4, reply_pct 5-45, important_pct 5-40, click_pct 0-70 and
at most important_pct x 1.75), ISO dates, the timezone offset, the email
types and the SMTP port.
Verified against the live API:
- check-domain-dmarc takes an email address. The docs describe a domain, but
a bare domain is answered with "Please provide a valid email address".
- test-smtp-configuration answers a failed SMTP login with HTTP 500.
The Warmup key and the SMTP credentials are redacted out of any error that
escapes the client, and the key is checked for whitespace and control
characters the same way the validation key is.
Co-Authored-By: Claude Opus 5
---
CMakeLists.txt | 2 +-
include/ZeroBounce/ZeroBounceWarmup.h | 239 +++++++
src/ZeroBounceWarmup.c | 877 ++++++++++++++++++++++++++
test/OfflineWarmupTest.c | 539 ++++++++++++++++
test/ZeroBounceTest.c | 2 +
5 files changed, 1658 insertions(+), 1 deletion(-)
create mode 100644 include/ZeroBounce/ZeroBounceWarmup.h
create mode 100644 src/ZeroBounceWarmup.c
create mode 100644 test/OfflineWarmupTest.c
diff --git a/CMakeLists.txt b/CMakeLists.txt
index d13fd48..b0e7956 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -57,6 +57,6 @@ target_include_directories(${PROJECT_NAME} PUBLIC include ${json-c_SOURCE_DIR})
target_link_libraries(${PROJECT_NAME} PUBLIC json-c libcurl)
enable_testing()
-add_executable(ZeroBounceTests test/ZeroBounceTest.c test/OfflineApiTest.c)
+add_executable(ZeroBounceTests test/ZeroBounceTest.c test/OfflineApiTest.c test/OfflineWarmupTest.c)
target_link_libraries(ZeroBounceTests PRIVATE ${PROJECT_NAME} unity)
target_include_directories(ZeroBounceTests PRIVATE ${fff_SOURCE_DIR})
diff --git a/include/ZeroBounce/ZeroBounceWarmup.h b/include/ZeroBounce/ZeroBounceWarmup.h
new file mode 100644
index 0000000..501297c
--- /dev/null
+++ b/include/ZeroBounce/ZeroBounceWarmup.h
@@ -0,0 +1,239 @@
+#ifndef ZEROBOUNCEWARMUP_H
+#define ZEROBOUNCEWARMUP_H
+
+/**
+ * Client for the ZeroBounce Warmup API (https://www.zerobounce.net/docs/warmup-api).
+ *
+ * The Warmup API is a separate product from email validation: 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 key, and the two never mix.
+ *
+ * Every call hands the raw JSON body to its success callback, or an empty
+ * string for the documented empty successes (HTTP 204 and an empty 200). The
+ * two download endpoints hand over CSV text instead.
+ */
+
+#include
+
+#include "ZeroBounce/ZeroBounce.h"
+
+/** Onboarding models accepted by warmup_add_sending_account. */
+#define ZB_WARMUP_SELF_MANAGED "self_managed"
+#define ZB_WARMUP_ZB_MANAGED "zb_managed"
+
+/**
+ * The Warmup client. One instance per Warmup API key; the key is bound to one
+ * team.
+ */
+typedef struct {
+ char *api_key;
+ const char *base_url;
+} ZeroBounceWarmup;
+
+/** ZB managed onboarding only. A NULL field is left out of the request. */
+typedef struct {
+ const char *company_name;
+ const char *company_website;
+ const char *company_description;
+ /** Any of transactional, newsletter, marketing, comma separated. */
+ const char *types_of_emails;
+ const char *example_email;
+} ZBWarmupEmailContent;
+
+/** ZB managed onboarding only, and write-only on the API. */
+typedef struct {
+ /** -1 to omit, 0 false, 1 true. */
+ int weekdays_only;
+ const char *timezone;
+ /** -99 to omit, otherwise -11 to 14. */
+ int timezone_offset;
+ /** YYYY-MM-DD, or NULL to omit. */
+ const char *warmup_start_date;
+} ZBWarmupManagedSending;
+
+/** Options for warmup_add_sending_account. */
+typedef struct {
+ /** Required for self_managed (0 to 4), ignored for zb_managed; -1 omits. */
+ int engagement_rule_id;
+ /** Self-managed only: -1 to omit, 0 false, 1 true. */
+ int ignore_conflict;
+ /** ZB managed only; NULL to omit. */
+ const ZBWarmupEmailContent *email_content;
+ /** ZB managed only; NULL to omit. */
+ const ZBWarmupManagedSending *managed_sending;
+} ZBWarmupAddSendingAccountOptions;
+
+/** Options for warmup_list_from_addresses. */
+typedef struct {
+ /** Letters, digits and . - _ @ + only; NULL to omit. */
+ const char *search_from;
+ /** -1 to omit. The API defaults to 0. */
+ int offset;
+ /** -1 to omit. The API defaults to 100. */
+ int limit;
+} ZBWarmupListFromAddressesOptions;
+
+ZBWarmupEmailContent new_warmup_email_content(void);
+ZBWarmupManagedSending new_warmup_managed_sending(void);
+ZBWarmupAddSendingAccountOptions new_warmup_add_sending_account_options(void);
+ZBWarmupListFromAddressesOptions new_warmup_list_from_addresses_options(void);
+
+/** A new client, with the default base URL and no key yet. */
+ZeroBounceWarmup *new_zero_bounce_warmup_instance(void);
+/** Sets the Warmup API key. It is sent in an API-Key header. */
+void zero_bounce_warmup_initialize(ZeroBounceWarmup *zbw, const char *api_key);
+/** Overrides the base URL ZeroBounce gave you. Must be https. */
+void zero_bounce_warmup_set_base_url(ZeroBounceWarmup *zbw, const char *url);
+/** Frees the client and its key. */
+void zero_bounce_warmup_free(ZeroBounceWarmup *zbw);
+
+/* -- sending accounts ---------------------------------------------------- */
+
+/** Every sending account of the key's team. An empty team answers an empty list. */
+void warmup_list_sending_accounts(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+void warmup_get_sending_account(
+ ZeroBounceWarmup *zbw, const char *from_address,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/**
+ * Adds a from-address to warmup. For a ZB managed account a 200 means
+ * accepted; the mailbox is provisioned afterwards.
+ */
+void warmup_add_sending_account(
+ ZeroBounceWarmup *zbw, const char *from_address, const char *onboarding_model,
+ const ZBWarmupAddSendingAccountOptions *options,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/**
+ * Changes an account's engagement rule, status and auto-enable flag.
+ * status_id is 0 to 8 (4 Paused, 5 Disabled); auto_enable is accepted only
+ * for an account that is not active.
+ */
+void warmup_update_sending_account(
+ ZeroBounceWarmup *zbw, const char *from_address, int engagement_rule_id, int status_id,
+ bool auto_enable, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/** Removes a sending account. Destructive and cannot be undone. */
+void warmup_delete_sending_account(
+ ZeroBounceWarmup *zbw, const char *from_address,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/**
+ * Moves an account and its history to a new from-address. Despite the path it
+ * uses, nothing about SMTP credentials is stored.
+ */
+void warmup_change_from_address(
+ ZeroBounceWarmup *zbw, const char *old_from_address, const char *new_from_address,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+void warmup_list_from_addresses(
+ ZeroBounceWarmup *zbw, const ZBWarmupListFromAddressesOptions *options,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/* -- engagement rules ---------------------------------------------------- */
+
+/** The rule catalogue: -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. */
+void warmup_get_engagement_rules(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/** Sets a standard rule (0 to 4) on an address. */
+void warmup_set_engagement_rule(
+ ZeroBounceWarmup *zbw, const char *c_from, int rule_id,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+void warmup_get_custom_engagement(
+ ZeroBounceWarmup *zbw, const char *c_from,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/**
+ * Creates a custom rule. reply_pct 5-45, important_pct 5-40, click_pct 0-70
+ * and at most important_pct x 1.75. Starts a 7 day cooldown on updates.
+ */
+void warmup_create_custom_engagement(
+ ZeroBounceWarmup *zbw, const char *c_from, int reply_pct, int important_pct, int click_pct,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/** Updates a custom rule. During the 7 day cooldown this fails with HTTP 429. */
+void warmup_update_custom_engagement(
+ ZeroBounceWarmup *zbw, const char *c_from, int reply_pct, int important_pct, int click_pct,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/** Deletes a custom rule, falling back to standard rule engagement_rule_id (1 to 4). */
+void warmup_delete_custom_engagement(
+ ZeroBounceWarmup *zbw, const char *c_from, int engagement_rule_id,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/* -- seeds, progress and statistics -------------------------------------- */
+
+void warmup_get_seeds(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/**
+ * The seed list as CSV (columns seed,provider). When download_path is not
+ * NULL the body is written there as well.
+ */
+void warmup_download_seeds(
+ ZeroBounceWarmup *zbw, const char *download_path,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/** The number of seeds. Answers a bare number, not an object. */
+void warmup_get_seed_count(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+void warmup_get_progress_today(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+void warmup_get_sent_today(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+void warmup_get_emails_per_day(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/** Statistics for an address. An address with no history answers zeros. */
+void warmup_get_statistics(
+ ZeroBounceWarmup *zbw, const char *from_address,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/** Daily statistics between two YYYY-MM-DD dates, inclusive. */
+void warmup_get_statistics_per_day(
+ ZeroBounceWarmup *zbw, const char *from_address, const char *start_date, const char *end_date,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+void warmup_get_statistics_detailed(
+ ZeroBounceWarmup *zbw, const char *c_from,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/** An address's statistics as CSV; also written to download_path when set. */
+void warmup_download_statistics(
+ ZeroBounceWarmup *zbw, const char *c_from, const char *download_path,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+void warmup_get_from_address_statistics(
+ ZeroBounceWarmup *zbw, const char *from_address,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/* -- deliverability checks ----------------------------------------------- */
+
+/**
+ * SPF, DKIM and DMARC status of a from-address's domain. Pass an email
+ * address: the live API answers "Please provide a valid email address" for a
+ * bare domain, although the docs describe a domain. dkim_selector may be NULL.
+ */
+void warmup_check_domain_dmarc(
+ ZeroBounceWarmup *zbw, const char *from_address, const char *dkim_selector,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+/**
+ * Checks that SMTP credentials connect and hand off a message. Nothing is
+ * stored, and the test message goes to a ZeroBounce test recipient, not
+ * from_address. The live API answers a failed SMTP login with HTTP 500.
+ */
+void warmup_test_smtp_configuration(
+ ZeroBounceWarmup *zbw, const char *from_address, const char *username, const char *password,
+ const char *server, int port,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback);
+
+#endif
diff --git a/src/ZeroBounceWarmup.c b/src/ZeroBounceWarmup.c
new file mode 100644
index 0000000..d087593
--- /dev/null
+++ b/src/ZeroBounceWarmup.c
@@ -0,0 +1,877 @@
+#include
+#include
+#include
+#include
+
+#include
+
+#include "ZeroBounce/ZeroBounceWarmup.h"
+#include "ZeroBounce/zb_internal.h"
+
+#define ZB_WARMUP_DEFAULT_BASE_URL "https://warmup-api.zerobounce.net"
+#define ZB_WARMUP_BASE_PATH "/api/v1/warmup"
+/* team_id is required by the schema but always replaced with the key's team. */
+#define ZB_WARMUP_ANY_TEAM_ID 1
+
+ZBWarmupEmailContent new_warmup_email_content(void) {
+ ZBWarmupEmailContent content = {NULL, NULL, NULL, NULL, NULL};
+ return content;
+}
+
+ZBWarmupManagedSending new_warmup_managed_sending(void) {
+ ZBWarmupManagedSending sending = {-1, NULL, -99, NULL};
+ return sending;
+}
+
+ZBWarmupAddSendingAccountOptions new_warmup_add_sending_account_options(void) {
+ ZBWarmupAddSendingAccountOptions options = {-1, -1, NULL, NULL};
+ return options;
+}
+
+ZBWarmupListFromAddressesOptions new_warmup_list_from_addresses_options(void) {
+ ZBWarmupListFromAddressesOptions options = {NULL, -1, -1};
+ return options;
+}
+
+ZeroBounceWarmup *new_zero_bounce_warmup_instance(void) {
+ ZeroBounceWarmup *zbw = malloc(sizeof(ZeroBounceWarmup));
+ if (!zbw) {
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ zbw->api_key = NULL;
+ zbw->base_url = ZB_WARMUP_DEFAULT_BASE_URL;
+ return zbw;
+}
+
+void zero_bounce_warmup_initialize(ZeroBounceWarmup *zbw, const char *api_key) {
+ if (!zbw) {
+ return;
+ }
+ if (!zb_api_key_is_clean(api_key)) {
+ fprintf(stderr,
+ "ZeroBounce: the Warmup api_key has surrounding whitespace or control "
+ "characters; it was not accepted.\n");
+ return;
+ }
+ free(zbw->api_key);
+ zbw->api_key = api_key ? strdup(api_key) : NULL;
+}
+
+void zero_bounce_warmup_set_base_url(ZeroBounceWarmup *zbw, const char *url) {
+ if (zbw && url && url[0]) {
+ zbw->base_url = url;
+ }
+}
+
+void zero_bounce_warmup_free(ZeroBounceWarmup *zbw) {
+ if (!zbw) {
+ return;
+ }
+ free(zbw->api_key);
+ free(zbw);
+}
+
+/* -- argument checks ------------------------------------------------------ */
+
+static bool warmup_is_blank(const char *value) {
+ const char *p;
+ if (!value) {
+ return true;
+ }
+ for (p = value; *p; p++) {
+ if (!isspace((unsigned char)*p)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+static bool warmup_ready(ZeroBounceWarmup *zbw, OnErrorCallback error_callback) {
+ if (zbw && zbw->api_key && zbw->api_key[0]) {
+ return true;
+ }
+ if (error_callback) {
+ error_callback(parse_error(
+ "The Warmup API is not initialized. Please call "
+ "zero_bounce_warmup_initialize(zbw_instance, api_key); first"
+ ));
+ }
+ return false;
+}
+
+static bool warmup_required(const char *value, const char *name, OnErrorCallback error_callback) {
+ char message[128];
+ if (!warmup_is_blank(value)) {
+ return true;
+ }
+ snprintf(message, sizeof(message), "Empty parameter: %s", name);
+ if (error_callback) {
+ error_callback(parse_error(message));
+ }
+ return false;
+}
+
+static bool warmup_in_range(int value, int low, int high, const char *name, OnErrorCallback error_callback) {
+ char message[160];
+ if (value >= low && value <= high) {
+ return true;
+ }
+ snprintf(message, sizeof(message), "Invalid parameter: %s (must be %d to %d)", name, low, high);
+ if (error_callback) {
+ error_callback(parse_error(message));
+ }
+ return false;
+}
+
+static bool warmup_iso_date(const char *value, const char *name, OnErrorCallback error_callback) {
+ char message[160];
+ int i;
+ bool ok = value && strlen(value) == 10;
+ for (i = 0; ok && i < 10; i++) {
+ if (i == 4 || i == 7) {
+ ok = value[i] == '-';
+ } else {
+ ok = isdigit((unsigned char)value[i]) != 0;
+ }
+ }
+ if (ok) {
+ return true;
+ }
+ snprintf(message, sizeof(message), "Invalid parameter: %s (expected YYYY-MM-DD)", name);
+ if (error_callback) {
+ error_callback(parse_error(message));
+ }
+ return false;
+}
+
+/* -- request plumbing ----------------------------------------------------- */
+
+/* Appends "name=value" to a query string, with the right separator. */
+static void warmup_add_query(char *query, size_t query_len, const char *name, const char *value) {
+ char *escaped = zb_url_escape(value ? value : "");
+ size_t used = strlen(query);
+ snprintf(query + used, query_len - used, "%s%s=%s",
+ used ? "&" : "?", name, escaped ? escaped : "");
+ free(escaped);
+}
+
+/*
+ * The Warmup key never travels in a URL, but a transport error may quote the
+ * header, and test-smtp-configuration carries SMTP credentials in its body.
+ */
+static char *warmup_redact(const char *text, const char *const *secrets, int secret_count) {
+ char *out = strdup(text ? text : "");
+ int i;
+ if (!out) {
+ return NULL;
+ }
+ for (i = 0; i < secret_count; i++) {
+ const char *secret = secrets[i];
+ char *at;
+ size_t len;
+ if (!secret || !secret[0]) {
+ continue;
+ }
+ len = strlen(secret);
+ while ((at = strstr(out, secret)) != NULL) {
+ memmove(at + 3, at + len, strlen(at + len) + 1);
+ memcpy(at, "***", 3);
+ }
+ }
+ return out;
+}
+
+/* Reports an error with the secrets taken out of its message. */
+static void warmup_report(
+ ZBErrorResponse error, const char *const *secrets, int secret_count,
+ OnErrorCallback error_callback
+) {
+ size_t i;
+ if (!error_callback) {
+ return;
+ }
+ for (i = 0; i < error.errors.size; i++) {
+ char *safe = warmup_redact(error.errors.data[i], secrets, secret_count);
+ if (safe) {
+ free(error.errors.data[i]);
+ error.errors.data[i] = safe;
+ }
+ }
+ error_callback(error);
+}
+
+typedef struct {
+ OnErrorCallback error_callback;
+ const char *const *secrets;
+ int secret_count;
+} WarmupErrorCtx;
+
+static _Thread_local WarmupErrorCtx warmup_error_ctx;
+
+static void warmup_on_error(ZBErrorResponse error) {
+ warmup_report(error, warmup_error_ctx.secrets, warmup_error_ctx.secret_count,
+ warmup_error_ctx.error_callback);
+}
+
+/*
+ * One Warmup request. The body is handed to success_callback raw; HTTP 204
+ * and an empty 200 are the documented empty successes and come back as "".
+ */
+static void warmup_send(
+ ZeroBounceWarmup *zbw,
+ const char *method,
+ const char *path,
+ const char *query,
+ const char *body,
+ const char *const *secrets,
+ int secret_count,
+ bool expect_csv,
+ const char *download_path,
+ OnSuccessCallbackRawJson success_callback,
+ OnErrorCallback error_callback
+) {
+ char *url_path;
+ char *auth_header;
+ int url_len;
+ int auth_len;
+ memory response_data = {0};
+ long http_code = 0;
+ const char *text;
+
+ if (!warmup_ready(zbw, error_callback)) {
+ return;
+ }
+
+ url_len = snprintf(NULL, 0, "%s%s%s%s", zbw->base_url, ZB_WARMUP_BASE_PATH, path, query ? query : "");
+ url_path = malloc((size_t)url_len + 1);
+ auth_len = snprintf(NULL, 0, "API-Key: %s", zbw->api_key);
+ auth_header = malloc((size_t)auth_len + 1);
+ if (!url_path || !auth_header) {
+ free(url_path);
+ free(auth_header);
+ fprintf(stderr, "Memory allocation failed.\n");
+ exit(EXIT_FAILURE);
+ }
+ snprintf(url_path, (size_t)url_len + 1, "%s%s%s%s",
+ zbw->base_url, ZB_WARMUP_BASE_PATH, path, query ? query : "");
+ snprintf(auth_header, (size_t)auth_len + 1, "API-Key: %s", zbw->api_key);
+
+ warmup_error_ctx.error_callback = error_callback;
+ warmup_error_ctx.secrets = secrets;
+ warmup_error_ctx.secret_count = secret_count;
+
+ if (!zb_make_request_with_extra_header(
+ url_path, method, body, "Content-Type: application/json", auth_header,
+ &response_data, &http_code, warmup_on_error)) {
+ free(auth_header);
+ free(url_path);
+ free(response_data.response);
+ return;
+ }
+
+ text = response_data.response ? response_data.response : "";
+ if (http_code > 299 || zb_body_looks_like_html(text)) {
+ zb_report_body_error(text, http_code, warmup_on_error);
+ } else if (expect_csv) {
+ /* An error comes back labelled as JSON, so it is not a file. */
+ if (!text[0] || text[0] == '{' || text[0] == '[') {
+ zb_report_body_error(text, http_code, warmup_on_error);
+ } else {
+ if (download_path && download_path[0]) {
+ FILE *handle = fopen(download_path, "wb");
+ if (handle) {
+ fwrite(text, 1, strlen(text), handle);
+ fclose(handle);
+ } else if (error_callback) {
+ error_callback(parse_error("Could not open download_path for writing"));
+ }
+ }
+ if (success_callback) {
+ success_callback(text);
+ }
+ }
+ } else if (success_callback) {
+ /* 204 and an empty 200 are the documented empty successes. */
+ success_callback(text);
+ }
+
+ free(auth_header);
+ free(url_path);
+ free(response_data.response);
+}
+
+static void warmup_json(
+ ZeroBounceWarmup *zbw, const char *method, const char *path, const char *query,
+ const char *body, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ warmup_send(zbw, method, path, query, body, NULL, 0, false, NULL,
+ success_callback, error_callback);
+}
+
+/* -- sending accounts ----------------------------------------------------- */
+
+void warmup_list_sending_accounts(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ warmup_json(zbw, "GET", "/sending-accounts", NULL, NULL, success_callback, error_callback);
+}
+
+void warmup_get_sending_account(
+ ZeroBounceWarmup *zbw, const char *from_address,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char query[1024] = "";
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(from_address, "from_address", error_callback)) {
+ return;
+ }
+ warmup_add_query(query, sizeof(query), "from_address", from_address);
+ warmup_json(zbw, "GET", "/sending-account", query, NULL, success_callback, error_callback);
+}
+
+void warmup_add_sending_account(
+ ZeroBounceWarmup *zbw, const char *from_address, const char *onboarding_model,
+ const ZBWarmupAddSendingAccountOptions *options,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ json_object *payload;
+ bool self_managed;
+
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(from_address, "from_address", error_callback)) {
+ return;
+ }
+ if (!onboarding_model ||
+ (strcmp(onboarding_model, ZB_WARMUP_SELF_MANAGED) != 0 &&
+ strcmp(onboarding_model, ZB_WARMUP_ZB_MANAGED) != 0)) {
+ if (error_callback) {
+ error_callback(parse_error(
+ "Invalid parameter: onboarding_model (must be self_managed or zb_managed)"));
+ }
+ return;
+ }
+ self_managed = strcmp(onboarding_model, ZB_WARMUP_SELF_MANAGED) == 0;
+
+ if (self_managed) {
+ if (!options || options->engagement_rule_id < 0) {
+ if (error_callback) {
+ error_callback(parse_error(
+ "Empty parameter: engagement_rule_id (required for self_managed)"));
+ }
+ return;
+ }
+ if (!warmup_in_range(options->engagement_rule_id, 0, 4, "engagement_rule_id", error_callback)) {
+ return;
+ }
+ if (options->email_content || options->managed_sending) {
+ if (error_callback) {
+ error_callback(parse_error(
+ "Invalid parameter: email_content and managed_sending are for zb_managed"));
+ }
+ return;
+ }
+ }
+
+ payload = json_object_new_object();
+ json_object_object_add(payload, "from_address", json_object_new_string(from_address));
+ json_object_object_add(payload, "onboarding_model", json_object_new_string(onboarding_model));
+ if (self_managed) {
+ json_object_object_add(
+ payload, "engagement_rule_id", json_object_new_int(options->engagement_rule_id));
+ }
+ if (options && options->ignore_conflict >= 0) {
+ json_object_object_add(
+ payload, "ignore_conflict", json_object_new_boolean(options->ignore_conflict ? 1 : 0));
+ }
+ if (options && options->email_content) {
+ const ZBWarmupEmailContent *content = options->email_content;
+ json_object *node = json_object_new_object();
+ if (content->company_name) {
+ json_object_object_add(node, "company_name", json_object_new_string(content->company_name));
+ }
+ if (content->company_website) {
+ json_object_object_add(node, "company_website", json_object_new_string(content->company_website));
+ }
+ if (content->company_description) {
+ json_object_object_add(
+ node, "company_description", json_object_new_string(content->company_description));
+ }
+ if (content->types_of_emails) {
+ json_object *types = json_object_new_array();
+ const char *p = content->types_of_emails;
+ while (*p) {
+ char item[32];
+ size_t n = 0;
+ while (*p == ' ' || *p == ',') {
+ p++;
+ }
+ while (*p && *p != ',' && n + 1 < sizeof(item)) {
+ item[n++] = (char)tolower((unsigned char)*p++);
+ }
+ while (n > 0 && item[n - 1] == ' ') {
+ n--;
+ }
+ item[n] = '\0';
+ if (!n) {
+ continue;
+ }
+ if (strcmp(item, "transactional") != 0 && strcmp(item, "newsletter") != 0 &&
+ strcmp(item, "marketing") != 0) {
+ json_object_put(types);
+ json_object_put(node);
+ json_object_put(payload);
+ if (error_callback) {
+ error_callback(parse_error(
+ "Invalid parameter: types_of_emails "
+ "(transactional, newsletter and/or marketing)"));
+ }
+ return;
+ }
+ json_object_array_add(types, json_object_new_string(item));
+ }
+ json_object_object_add(node, "types_of_emails", types);
+ }
+ if (content->example_email) {
+ json_object_object_add(node, "example_email", json_object_new_string(content->example_email));
+ }
+ json_object_object_add(payload, "email_content", node);
+ }
+ if (options && options->managed_sending) {
+ const ZBWarmupManagedSending *sending = options->managed_sending;
+ json_object *node = json_object_new_object();
+ if (sending->weekdays_only >= 0) {
+ json_object_object_add(
+ node, "weekdays_only", json_object_new_boolean(sending->weekdays_only ? 1 : 0));
+ }
+ if (sending->timezone) {
+ json_object_object_add(node, "timezone", json_object_new_string(sending->timezone));
+ }
+ if (sending->timezone_offset != -99) {
+ if (!warmup_in_range(sending->timezone_offset, -11, 14, "timezone_offset", error_callback)) {
+ json_object_put(node);
+ json_object_put(payload);
+ return;
+ }
+ json_object_object_add(
+ node, "timezone_offset", json_object_new_int(sending->timezone_offset));
+ }
+ if (sending->warmup_start_date) {
+ if (!warmup_iso_date(sending->warmup_start_date, "warmup_start_date", error_callback)) {
+ json_object_put(node);
+ json_object_put(payload);
+ return;
+ }
+ json_object_object_add(
+ node, "warmup_start_date", json_object_new_string(sending->warmup_start_date));
+ }
+ json_object_object_add(payload, "managed_sending", node);
+ }
+
+ warmup_json(zbw, "POST", "/sending-accounts", NULL,
+ json_object_to_json_string(payload), success_callback, error_callback);
+ json_object_put(payload);
+}
+
+void warmup_update_sending_account(
+ ZeroBounceWarmup *zbw, const char *from_address, int engagement_rule_id, int status_id,
+ bool auto_enable, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ json_object *payload;
+
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(from_address, "from_address", error_callback) ||
+ !warmup_in_range(engagement_rule_id, 0, 4, "engagement_rule_id", error_callback) ||
+ !warmup_in_range(status_id, 0, 8, "status_id", error_callback)) {
+ return;
+ }
+ payload = json_object_new_object();
+ json_object_object_add(payload, "team_id", json_object_new_int(ZB_WARMUP_ANY_TEAM_ID));
+ json_object_object_add(payload, "from_address", json_object_new_string(from_address));
+ json_object_object_add(payload, "engagement_rule_id", json_object_new_int(engagement_rule_id));
+ json_object_object_add(payload, "status_id", json_object_new_int(status_id));
+ json_object_object_add(payload, "auto_enable", json_object_new_boolean(auto_enable ? 1 : 0));
+ warmup_json(zbw, "PUT", "/sending-accounts", NULL,
+ json_object_to_json_string(payload), success_callback, error_callback);
+ json_object_put(payload);
+}
+
+void warmup_delete_sending_account(
+ ZeroBounceWarmup *zbw, const char *from_address,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char query[1024] = "";
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(from_address, "from_address", error_callback)) {
+ return;
+ }
+ warmup_add_query(query, sizeof(query), "from_address", from_address);
+ warmup_json(zbw, "DELETE", "/sending-accounts", query, NULL, success_callback, error_callback);
+}
+
+void warmup_change_from_address(
+ ZeroBounceWarmup *zbw, const char *old_from_address, const char *new_from_address,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ json_object *payload;
+
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(old_from_address, "old_from_address", error_callback) ||
+ !warmup_required(new_from_address, "new_from_address", error_callback)) {
+ return;
+ }
+ payload = json_object_new_object();
+ json_object_object_add(payload, "team_id", json_object_new_int(ZB_WARMUP_ANY_TEAM_ID));
+ json_object_object_add(payload, "old_from_address", json_object_new_string(old_from_address));
+ json_object_object_add(payload, "new_from_address", json_object_new_string(new_from_address));
+ warmup_json(zbw, "PUT", "/smtp-credentials", NULL,
+ json_object_to_json_string(payload), success_callback, error_callback);
+ json_object_put(payload);
+}
+
+void warmup_list_from_addresses(
+ ZeroBounceWarmup *zbw, const ZBWarmupListFromAddressesOptions *options,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char query[1024] = "";
+ char number[32];
+
+ if (!warmup_ready(zbw, error_callback)) {
+ return;
+ }
+ if (options && options->search_from) {
+ const char *p;
+ for (p = options->search_from; *p; p++) {
+ if (!isalnum((unsigned char)*p) && !strchr(".-_@+", *p)) {
+ if (error_callback) {
+ error_callback(parse_error(
+ "Invalid parameter: search_from (letters, digits and . - _ @ + only)"));
+ }
+ return;
+ }
+ }
+ warmup_add_query(query, sizeof(query), "search_from", options->search_from);
+ }
+ /* The API defaults these to 0 and 100. */
+ if (options && options->offset >= 0) {
+ snprintf(number, sizeof(number), "%d", options->offset);
+ warmup_add_query(query, sizeof(query), "offset", number);
+ }
+ if (options && options->limit >= 0) {
+ snprintf(number, sizeof(number), "%d", options->limit);
+ warmup_add_query(query, sizeof(query), "limit", number);
+ }
+ warmup_json(zbw, "GET", "/from-addresses", query, NULL, success_callback, error_callback);
+}
+
+/* -- engagement rules ----------------------------------------------------- */
+
+void warmup_get_engagement_rules(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ warmup_json(zbw, "GET", "/engagement-rules", NULL, NULL, success_callback, error_callback);
+}
+
+void warmup_set_engagement_rule(
+ ZeroBounceWarmup *zbw, const char *c_from, int rule_id,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char query[1024] = "";
+ json_object *payload;
+
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(c_from, "c_from", error_callback) ||
+ !warmup_in_range(rule_id, 0, 4, "rule_id", error_callback)) {
+ return;
+ }
+ warmup_add_query(query, sizeof(query), "c_from", c_from);
+ payload = json_object_new_object();
+ json_object_object_add(payload, "rule_id", json_object_new_int(rule_id));
+ warmup_json(zbw, "PUT", "/engagement-rule", query,
+ json_object_to_json_string(payload), success_callback, error_callback);
+ json_object_put(payload);
+}
+
+void warmup_get_custom_engagement(
+ ZeroBounceWarmup *zbw, const char *c_from,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char path[640];
+ char *segment;
+
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(c_from, "c_from", error_callback)) {
+ return;
+ }
+ /* c_from is one path segment here, so a slash must stay encoded, and a
+ "." or ".." would walk the request out of the endpoint. */
+ if (strcmp(c_from, ".") == 0 || strcmp(c_from, "..") == 0) {
+ if (error_callback) {
+ error_callback(parse_error("Invalid parameter: c_from"));
+ }
+ return;
+ }
+ segment = zb_url_escape(c_from);
+ snprintf(path, sizeof(path), "/custom-engagement/%s", segment ? segment : "");
+ free(segment);
+ warmup_json(zbw, "GET", path, NULL, NULL, success_callback, error_callback);
+}
+
+/*
+ * reply_pct 5-45, important_pct 5-40, click_pct 0-70 and at most
+ * important_pct x 1.75. Returns NULL when an argument was rejected.
+ */
+static json_object *warmup_engagement_payload(
+ const char *c_from, int reply_pct, int important_pct, int click_pct,
+ OnErrorCallback error_callback
+) {
+ json_object *payload;
+
+ if (!warmup_required(c_from, "c_from", error_callback) ||
+ !warmup_in_range(reply_pct, 5, 45, "reply_pct", error_callback) ||
+ !warmup_in_range(important_pct, 5, 40, "important_pct", error_callback) ||
+ !warmup_in_range(click_pct, 0, 70, "click_pct", error_callback)) {
+ return NULL;
+ }
+ if (click_pct * 100 > important_pct * 175) {
+ if (error_callback) {
+ error_callback(parse_error("Invalid parameter: click_pct (at most important_pct x 1.75)"));
+ }
+ return NULL;
+ }
+ payload = json_object_new_object();
+ json_object_object_add(payload, "c_from", json_object_new_string(c_from));
+ json_object_object_add(payload, "reply_pct", json_object_new_int(reply_pct));
+ json_object_object_add(payload, "important_pct", json_object_new_int(important_pct));
+ json_object_object_add(payload, "click_pct", json_object_new_int(click_pct));
+ return payload;
+}
+
+void warmup_create_custom_engagement(
+ ZeroBounceWarmup *zbw, const char *c_from, int reply_pct, int important_pct, int click_pct,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ json_object *payload;
+ if (!warmup_ready(zbw, error_callback)) {
+ return;
+ }
+ payload = warmup_engagement_payload(c_from, reply_pct, important_pct, click_pct, error_callback);
+ if (!payload) {
+ return;
+ }
+ warmup_json(zbw, "POST", "/custom-engagement", NULL,
+ json_object_to_json_string(payload), success_callback, error_callback);
+ json_object_put(payload);
+}
+
+void warmup_update_custom_engagement(
+ ZeroBounceWarmup *zbw, const char *c_from, int reply_pct, int important_pct, int click_pct,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ json_object *payload;
+ if (!warmup_ready(zbw, error_callback)) {
+ return;
+ }
+ payload = warmup_engagement_payload(c_from, reply_pct, important_pct, click_pct, error_callback);
+ if (!payload) {
+ return;
+ }
+ warmup_json(zbw, "PUT", "/custom-engagement", NULL,
+ json_object_to_json_string(payload), success_callback, error_callback);
+ json_object_put(payload);
+}
+
+void warmup_delete_custom_engagement(
+ ZeroBounceWarmup *zbw, const char *c_from, int engagement_rule_id,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ json_object *payload;
+
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(c_from, "c_from", error_callback) ||
+ !warmup_in_range(engagement_rule_id, 1, 4, "engagement_rule_id", error_callback)) {
+ return;
+ }
+ payload = json_object_new_object();
+ json_object_object_add(payload, "c_from", json_object_new_string(c_from));
+ json_object_object_add(payload, "engagement_rule_id", json_object_new_int(engagement_rule_id));
+ warmup_json(zbw, "DELETE", "/custom-engagement", NULL,
+ json_object_to_json_string(payload), success_callback, error_callback);
+ json_object_put(payload);
+}
+
+/* -- seeds, progress and statistics --------------------------------------- */
+
+void warmup_get_seeds(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ warmup_json(zbw, "GET", "/seeds", NULL, NULL, success_callback, error_callback);
+}
+
+void warmup_download_seeds(
+ ZeroBounceWarmup *zbw, const char *download_path,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ warmup_send(zbw, "GET", "/seeds/download", NULL, NULL, NULL, 0, true, download_path,
+ success_callback, error_callback);
+}
+
+void warmup_get_seed_count(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ warmup_json(zbw, "GET", "/seed-count", NULL, NULL, success_callback, error_callback);
+}
+
+void warmup_get_progress_today(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ warmup_json(zbw, "GET", "/progress-today", NULL, NULL, success_callback, error_callback);
+}
+
+void warmup_get_sent_today(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ warmup_json(zbw, "GET", "/sent-today", NULL, NULL, success_callback, error_callback);
+}
+
+void warmup_get_emails_per_day(
+ ZeroBounceWarmup *zbw, OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ warmup_json(zbw, "GET", "/emails-per-day", NULL, NULL, success_callback, error_callback);
+}
+
+void warmup_get_statistics(
+ ZeroBounceWarmup *zbw, const char *from_address,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char query[1024] = "";
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(from_address, "from_address", error_callback)) {
+ return;
+ }
+ warmup_add_query(query, sizeof(query), "from_address", from_address);
+ warmup_json(zbw, "GET", "/statistics", query, NULL, success_callback, error_callback);
+}
+
+void warmup_get_statistics_per_day(
+ ZeroBounceWarmup *zbw, const char *from_address, const char *start_date, const char *end_date,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char query[1024] = "";
+
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(from_address, "from_address", error_callback) ||
+ !warmup_iso_date(start_date, "start_date", error_callback) ||
+ !warmup_iso_date(end_date, "end_date", error_callback)) {
+ return;
+ }
+ if (strcmp(start_date, end_date) > 0) {
+ if (error_callback) {
+ error_callback(parse_error("Invalid parameter: start_date is later than end_date"));
+ }
+ return;
+ }
+ warmup_add_query(query, sizeof(query), "from_address", from_address);
+ warmup_add_query(query, sizeof(query), "start_date", start_date);
+ warmup_add_query(query, sizeof(query), "end_date", end_date);
+ warmup_json(zbw, "GET", "/statistics-per-day", query, NULL, success_callback, error_callback);
+}
+
+void warmup_get_statistics_detailed(
+ ZeroBounceWarmup *zbw, const char *c_from,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char query[1024] = "";
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(c_from, "c_from", error_callback)) {
+ return;
+ }
+ warmup_add_query(query, sizeof(query), "c_from", c_from);
+ warmup_json(zbw, "GET", "/statistics-detailed", query, NULL, success_callback, error_callback);
+}
+
+void warmup_download_statistics(
+ ZeroBounceWarmup *zbw, const char *c_from, const char *download_path,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char query[1024] = "";
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(c_from, "c_from", error_callback)) {
+ return;
+ }
+ warmup_add_query(query, sizeof(query), "c_from", c_from);
+ warmup_send(zbw, "GET", "/statistics/download", query, NULL, NULL, 0, true, download_path,
+ success_callback, error_callback);
+}
+
+void warmup_get_from_address_statistics(
+ ZeroBounceWarmup *zbw, const char *from_address,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char query[1024] = "";
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(from_address, "from_address", error_callback)) {
+ return;
+ }
+ warmup_add_query(query, sizeof(query), "from_address", from_address);
+ warmup_json(zbw, "GET", "/from-address-statistics", query, NULL, success_callback, error_callback);
+}
+
+/* -- deliverability checks ------------------------------------------------ */
+
+void warmup_check_domain_dmarc(
+ ZeroBounceWarmup *zbw, const char *from_address, const char *dkim_selector,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ char query[1024] = "";
+
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(from_address, "from_address", error_callback)) {
+ return;
+ }
+ /* LIVE: this takes an email address. The docs describe a domain, but a
+ bare domain is answered with "Please provide a valid email address". */
+ if (!strchr(from_address, '@')) {
+ if (error_callback) {
+ error_callback(parse_error(
+ "Invalid parameter: from_address (an email address, e.g. sender@example.com)"));
+ }
+ return;
+ }
+ warmup_add_query(query, sizeof(query), "from_address", from_address);
+ if (dkim_selector && dkim_selector[0]) {
+ warmup_add_query(query, sizeof(query), "dkim_selector", dkim_selector);
+ }
+ warmup_json(zbw, "GET", "/check-domain-dmarc", query, NULL, success_callback, error_callback);
+}
+
+void warmup_test_smtp_configuration(
+ ZeroBounceWarmup *zbw, const char *from_address, const char *username, const char *password,
+ const char *server, int port,
+ OnSuccessCallbackRawJson success_callback, OnErrorCallback error_callback
+) {
+ json_object *payload;
+ const char *secrets[2];
+
+ if (!warmup_ready(zbw, error_callback) ||
+ !warmup_required(from_address, "from_address", error_callback) ||
+ !warmup_required(username, "username", error_callback) ||
+ !warmup_required(password, "password", error_callback) ||
+ !warmup_required(server, "server", error_callback) ||
+ !warmup_in_range(port, 1, 65535, "port", error_callback)) {
+ return;
+ }
+ secrets[0] = password;
+ secrets[1] = username;
+
+ payload = json_object_new_object();
+ json_object_object_add(payload, "from_address", json_object_new_string(from_address));
+ json_object_object_add(payload, "username", json_object_new_string(username));
+ json_object_object_add(payload, "password", json_object_new_string(password));
+ json_object_object_add(payload, "server", json_object_new_string(server));
+ json_object_object_add(payload, "port", json_object_new_int(port));
+ warmup_send(zbw, "POST", "/test-smtp-configuration", NULL,
+ json_object_to_json_string(payload), secrets, 2, false, NULL,
+ success_callback, error_callback);
+ json_object_put(payload);
+}
diff --git a/test/OfflineWarmupTest.c b/test/OfflineWarmupTest.c
new file mode 100644
index 0000000..ac14801
--- /dev/null
+++ b/test/OfflineWarmupTest.c
@@ -0,0 +1,539 @@
+/*
+ * Offline tests for the Warmup API client.
+ *
+ * Every request is answered by the stub installed with
+ * zero_bounce_set_http_perform_for_test; nothing here reaches the live API.
+ * The assertions are on the url, the method, the body and the one header that
+ * actually went out.
+ */
+
+#include
+#include
+#include
+
+#include "unity.h"
+#include "ZeroBounce/ZeroBounce.h"
+#include "ZeroBounce/ZeroBounceWarmup.h"
+#include "ZeroBounce/zb_internal.h"
+
+#define WARMUP_ROOT "https://warmup-api.zerobounce.net/api/v1/warmup"
+
+static char w_url[2048];
+static char w_method[16];
+static char w_body[4096];
+static char w_header[512];
+static char w_raw[4096];
+static char w_error[1024];
+static int w_requests;
+static int w_error_called;
+static int w_success_called;
+static long w_status;
+static long w_next_status = 200;
+static const char *w_next_body = "{\"ok\":true}";
+
+static void w_on_raw(const char *json_response) {
+ w_success_called = 1;
+ snprintf(w_raw, sizeof(w_raw), "%s", json_response ? json_response : "");
+}
+
+static void w_on_error(ZBErrorResponse error) {
+ size_t i;
+ size_t used = 0;
+ w_error_called = 1;
+ w_status = error.status_code;
+ w_error[0] = '\0';
+ for (i = 0; i < error.errors.size; i++) {
+ const char *part = error.errors.data[i] ? error.errors.data[i] : "";
+ int written = snprintf(w_error + used, sizeof(w_error) - used, "%s%s", used ? " " : "", part);
+ if (written < 0 || (size_t)written >= sizeof(w_error) - used) {
+ break;
+ }
+ used += (size_t)written;
+ }
+}
+
+static int warmup_perform(memory *response_data, long *http_code, char **content_type_out) {
+ size_t len;
+ const char *url = zero_bounce_test_url();
+ const char *body = zero_bounce_test_body();
+ const char *header = zero_bounce_test_extra_header();
+
+ w_requests++;
+ snprintf(w_url, sizeof(w_url), "%s", url ? url : "");
+ snprintf(w_method, sizeof(w_method), "%s", zero_bounce_test_method());
+ snprintf(w_body, sizeof(w_body), "%s", body ? body : "");
+ snprintf(w_header, sizeof(w_header), "%s", header ? header : "");
+
+ len = strlen(w_next_body);
+ response_data->response = malloc(len + 1);
+ memcpy(response_data->response, w_next_body, len + 1);
+ response_data->size = len;
+ *http_code = w_next_status;
+ if (content_type_out) {
+ *content_type_out = strdup("application/json");
+ }
+ return 1;
+}
+
+static void w_reset(void) {
+ zero_bounce_set_http_perform_for_test(warmup_perform);
+ w_url[0] = w_method[0] = w_body[0] = w_header[0] = w_raw[0] = w_error[0] = '\0';
+ w_requests = 0;
+ w_error_called = 0;
+ w_success_called = 0;
+ w_status = 0;
+ w_next_status = 200;
+ w_next_body = "{\"ok\":true}";
+}
+
+static ZeroBounceWarmup *w_client(const char *key) {
+ ZeroBounceWarmup *zbw = new_zero_bounce_warmup_instance();
+ zero_bounce_warmup_initialize(zbw, key);
+ return zbw;
+}
+
+void test_warmup_api_key_header_and_own_host(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+
+ warmup_list_sending_accounts(zbw, w_on_raw, w_on_error);
+
+ TEST_ASSERT_EQUAL_STRING("GET", w_method);
+ /* Its own host and base path, not the validation API's. */
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/sending-accounts", w_url);
+ /* The Warmup API authenticates with an API-Key header; the validation
+ api_key never appears. */
+ TEST_ASSERT_EQUAL_STRING("API-Key: warm-key", w_header);
+ TEST_ASSERT_NULL(strstr(w_url, "api_key"));
+ TEST_ASSERT_TRUE(w_success_called);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_uninitialized_sends_nothing(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = new_zero_bounce_warmup_instance();
+
+ warmup_get_seeds(zbw, w_on_raw, w_on_error);
+
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "zero_bounce_warmup_initialize"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ /* A key with surrounding whitespace or control characters would be
+ quoted, unredacted, in a transport error. */
+ zero_bounce_warmup_initialize(zbw, " warm-key");
+ TEST_ASSERT_NULL(zbw->api_key);
+ zero_bounce_warmup_initialize(zbw, "warm-key");
+ TEST_ASSERT_EQUAL_STRING("warm-key", zbw->api_key);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_reads_use_the_documented_paths(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+
+ warmup_get_sending_account(zbw, "sender@example.com", w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/sending-account?from_address=sender%40example.com", w_url);
+
+ warmup_get_engagement_rules(zbw, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/engagement-rules", w_url);
+
+ warmup_get_seeds(zbw, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/seeds", w_url);
+
+ warmup_get_seed_count(zbw, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/seed-count", w_url);
+
+ warmup_get_progress_today(zbw, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/progress-today", w_url);
+
+ warmup_get_sent_today(zbw, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/sent-today", w_url);
+
+ warmup_get_emails_per_day(zbw, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/emails-per-day", w_url);
+
+ warmup_get_statistics(zbw, "sender@example.com", w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/statistics?from_address=sender%40example.com", w_url);
+
+ warmup_get_statistics_detailed(zbw, "sender@example.com", w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/statistics-detailed?c_from=sender%40example.com", w_url);
+
+ warmup_get_from_address_statistics(zbw, "sender@example.com", w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(
+ WARMUP_ROOT "/from-address-statistics?from_address=sender%40example.com", w_url);
+
+ /* c_from is one path segment here, so a slash must stay encoded. */
+ warmup_get_custom_engagement(zbw, "a b/c", w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/custom-engagement/a%20b%2Fc", w_url);
+
+ TEST_ASSERT_FALSE(w_error_called);
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_empty_successes(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+
+ /* 204 and an empty 200 are the documented empty successes. */
+ w_next_status = 204;
+ w_next_body = "";
+ warmup_get_sent_today(zbw, w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_success_called);
+ TEST_ASSERT_EQUAL_STRING("", w_raw);
+ TEST_ASSERT_FALSE(w_error_called);
+
+ w_next_status = 200;
+ w_next_body = "";
+ warmup_get_sent_today(zbw, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING("", w_raw);
+ TEST_ASSERT_FALSE(w_error_called);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_add_sending_account(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+ ZBWarmupAddSendingAccountOptions options = new_warmup_add_sending_account_options();
+ ZBWarmupEmailContent content = new_warmup_email_content();
+ ZBWarmupManagedSending sending = new_warmup_managed_sending();
+
+ options.engagement_rule_id = 2;
+ options.ignore_conflict = 1;
+ warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_SELF_MANAGED, &options,
+ w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING("POST", w_method);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/sending-accounts", w_url);
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"onboarding_model\": \"self_managed\""));
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"engagement_rule_id\": 2"));
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"ignore_conflict\": true"));
+
+ /* A self-managed account needs an engagement rule, and the ZB managed
+ extras belong to the other model. */
+ w_reset();
+ options = new_warmup_add_sending_account_options();
+ warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_SELF_MANAGED, &options,
+ w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "engagement_rule_id"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ w_reset();
+ options.engagement_rule_id = 0;
+ options.email_content = &content;
+ warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_SELF_MANAGED, &options,
+ w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "zb_managed"));
+
+ w_reset();
+ warmup_add_sending_account(zbw, "sender@example.com", "whatever", NULL, w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "onboarding_model"));
+
+ /* ZB managed takes the content and sending blocks. */
+ w_reset();
+ content.company_name = "Example";
+ content.types_of_emails = "newsletter, marketing";
+ sending.weekdays_only = 1;
+ sending.timezone_offset = 3;
+ sending.warmup_start_date = "2026-11-02";
+ options = new_warmup_add_sending_account_options();
+ options.email_content = &content;
+ options.managed_sending = &sending;
+ warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_ZB_MANAGED, &options,
+ w_on_raw, w_on_error);
+ TEST_ASSERT_FALSE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"company_name\": \"Example\""));
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"types_of_emails\": [ \"newsletter\", \"marketing\" ]"));
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"timezone_offset\": 3"));
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"warmup_start_date\": \"2026-11-02\""));
+ TEST_ASSERT_NULL(strstr(w_body, "engagement_rule_id"));
+
+ w_reset();
+ content.types_of_emails = "postcards";
+ warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_ZB_MANAGED, &options,
+ w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "types_of_emails"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ w_reset();
+ content.types_of_emails = "marketing";
+ sending.timezone_offset = 15;
+ warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_ZB_MANAGED, &options,
+ w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "timezone_offset"));
+
+ w_reset();
+ sending.timezone_offset = 3;
+ sending.warmup_start_date = "02/11/2026";
+ warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_ZB_MANAGED, &options,
+ w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "warmup_start_date"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_update_and_delete_sending_account(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+
+ warmup_update_sending_account(zbw, "sender@example.com", 1, 4, true, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING("PUT", w_method);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/sending-accounts", w_url);
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"team_id\": 1"));
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"status_id\": 4"));
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"auto_enable\": true"));
+
+ warmup_delete_sending_account(zbw, "sender@example.com", w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING("DELETE", w_method);
+ TEST_ASSERT_EQUAL_STRING(
+ WARMUP_ROOT "/sending-accounts?from_address=sender%40example.com", w_url);
+
+ /* Despite the path, nothing about SMTP credentials is stored. */
+ warmup_change_from_address(zbw, "old@example.com", "new@example.com", w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING("PUT", w_method);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/smtp-credentials", w_url);
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"old_from_address\": \"old@example.com\""));
+
+ w_reset();
+ warmup_update_sending_account(zbw, "sender@example.com", 9, 4, false, w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "engagement_rule_id"));
+
+ w_reset();
+ warmup_update_sending_account(zbw, "sender@example.com", 1, 9, false, w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "status_id"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_list_from_addresses(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+ ZBWarmupListFromAddressesOptions options = new_warmup_list_from_addresses_options();
+
+ warmup_list_from_addresses(zbw, NULL, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/from-addresses", w_url);
+
+ options.search_from = "sender@example.com";
+ options.offset = 20;
+ options.limit = 50;
+ warmup_list_from_addresses(zbw, &options, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(
+ WARMUP_ROOT "/from-addresses?search_from=sender%40example.com&offset=20&limit=50", w_url);
+
+ w_reset();
+ options.search_from = "a b";
+ warmup_list_from_addresses(zbw, &options, w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "search_from"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_engagement_rules(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+
+ warmup_set_engagement_rule(zbw, "sender@example.com", 3, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING("PUT", w_method);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/engagement-rule?c_from=sender%40example.com", w_url);
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"rule_id\": 3"));
+
+ warmup_create_custom_engagement(zbw, "sender@example.com", 10, 20, 30, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING("POST", w_method);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/custom-engagement", w_url);
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"reply_pct\": 10"));
+
+ warmup_update_custom_engagement(zbw, "sender@example.com", 10, 20, 30, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING("PUT", w_method);
+
+ warmup_delete_custom_engagement(zbw, "sender@example.com", 2, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING("DELETE", w_method);
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"engagement_rule_id\": 2"));
+
+ /* The ranges are checked before anything is sent. */
+ w_reset();
+ warmup_create_custom_engagement(zbw, "sender@example.com", 4, 20, 10, w_on_raw, w_on_error);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "reply_pct"));
+
+ w_reset();
+ warmup_create_custom_engagement(zbw, "sender@example.com", 10, 41, 10, w_on_raw, w_on_error);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "important_pct"));
+
+ /* click_pct is capped at important_pct x 1.75 as well as at 70. */
+ w_reset();
+ warmup_create_custom_engagement(zbw, "sender@example.com", 10, 20, 36, w_on_raw, w_on_error);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "click_pct"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ w_reset();
+ warmup_create_custom_engagement(zbw, "sender@example.com", 10, 20, 35, w_on_raw, w_on_error);
+ TEST_ASSERT_FALSE(w_error_called);
+ TEST_ASSERT_EQUAL_INT(1, w_requests);
+
+ /* Falling back to the custom rule itself is not a standard rule. */
+ w_reset();
+ warmup_delete_custom_engagement(zbw, "sender@example.com", 0, w_on_raw, w_on_error);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "engagement_rule_id"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_statistics_per_day_dates(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+
+ warmup_get_statistics_per_day(zbw, "sender@example.com", "2026-10-01", "2026-10-07",
+ w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(
+ WARMUP_ROOT "/statistics-per-day?from_address=sender%40example.com"
+ "&start_date=2026-10-01&end_date=2026-10-07", w_url);
+
+ w_reset();
+ warmup_get_statistics_per_day(zbw, "sender@example.com", "10/01/2026", "2026-10-07",
+ w_on_raw, w_on_error);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "start_date"));
+
+ w_reset();
+ warmup_get_statistics_per_day(zbw, "sender@example.com", "2026-10-08", "2026-10-07",
+ w_on_raw, w_on_error);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "later than"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_check_domain_dmarc_needs_an_email(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+
+ warmup_check_domain_dmarc(zbw, "sender@example.com", "selector1", w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(
+ WARMUP_ROOT "/check-domain-dmarc?from_address=sender%40example.com"
+ "&dkim_selector=selector1", w_url);
+
+ warmup_check_domain_dmarc(zbw, "sender@example.com", NULL, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(
+ WARMUP_ROOT "/check-domain-dmarc?from_address=sender%40example.com", w_url);
+
+ /* LIVE: the docs describe a domain, but a bare domain is answered with
+ "Please provide a valid email address". */
+ w_reset();
+ warmup_check_domain_dmarc(zbw, "example.com", NULL, w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "email address"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_test_smtp_configuration(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+
+ warmup_test_smtp_configuration(zbw, "sender@example.com", "sender@example.com", "sekrit",
+ "smtp.example.com", 587, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING("POST", w_method);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/test-smtp-configuration", w_url);
+ TEST_ASSERT_NOT_NULL(strstr(w_body, "\"port\": 587"));
+
+ /* LIVE: a failed SMTP login is answered with HTTP 500, and the password
+ must not travel out in the error. */
+ w_reset();
+ w_next_status = 500;
+ w_next_body = "{\"detail\":\"login failed for sekrit\"}";
+ warmup_test_smtp_configuration(zbw, "sender@example.com", "sender@example.com", "sekrit",
+ "smtp.example.com", 587, w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_EQUAL_INT(500, (int)w_status);
+ TEST_ASSERT_NULL(strstr(w_error, "sekrit"));
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "***"));
+
+ w_reset();
+ warmup_test_smtp_configuration(zbw, "sender@example.com", "sender@example.com", "sekrit",
+ "smtp.example.com", 0, w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "port"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_csv_downloads(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+
+ w_next_body = "seed,provider\na@example.com,gmail\n";
+ warmup_download_seeds(zbw, NULL, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(WARMUP_ROOT "/seeds/download", w_url);
+ TEST_ASSERT_EQUAL_INT(0, strncmp(w_raw, "seed,provider", 13));
+
+ w_next_body = "date,sent\n2026-10-01,10\n";
+ warmup_download_statistics(zbw, "sender@example.com", NULL, w_on_raw, w_on_error);
+ TEST_ASSERT_EQUAL_STRING(
+ WARMUP_ROOT "/statistics/download?c_from=sender%40example.com", w_url);
+
+ /* An error comes back as JSON, so it is not a file. */
+ w_reset();
+ w_next_body = "{\"detail\":\"nothing here\"}";
+ warmup_download_seeds(zbw, NULL, w_on_raw, w_on_error);
+ TEST_ASSERT_TRUE(w_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "nothing here"));
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void test_warmup_blank_arguments_are_rejected(void) {
+ w_reset();
+ ZeroBounceWarmup *zbw = w_client("warm-key");
+
+ warmup_get_sending_account(zbw, " ", w_on_raw, w_on_error);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "from_address"));
+
+ w_reset();
+ warmup_get_statistics_detailed(zbw, " ", w_on_raw, w_on_error);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "c_from"));
+
+ /* A path segment of "." or ".." would walk the request out of the
+ endpoint it was meant for. */
+ w_reset();
+ warmup_get_custom_engagement(zbw, "..", w_on_raw, w_on_error);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "c_from"));
+
+ w_reset();
+ warmup_change_from_address(zbw, "old@example.com", " ", w_on_raw, w_on_error);
+ TEST_ASSERT_NOT_NULL(strstr(w_error, "new_from_address"));
+ TEST_ASSERT_EQUAL_INT(0, w_requests);
+
+ zero_bounce_warmup_free(zbw);
+}
+
+void warmup_run_tests(void) {
+ RUN_TEST(test_warmup_api_key_header_and_own_host);
+ RUN_TEST(test_warmup_uninitialized_sends_nothing);
+ RUN_TEST(test_warmup_reads_use_the_documented_paths);
+ RUN_TEST(test_warmup_empty_successes);
+ RUN_TEST(test_warmup_add_sending_account);
+ RUN_TEST(test_warmup_update_and_delete_sending_account);
+ RUN_TEST(test_warmup_list_from_addresses);
+ RUN_TEST(test_warmup_engagement_rules);
+ RUN_TEST(test_warmup_statistics_per_day_dates);
+ RUN_TEST(test_warmup_check_domain_dmarc_needs_an_email);
+ RUN_TEST(test_warmup_test_smtp_configuration);
+ RUN_TEST(test_warmup_csv_downloads);
+ RUN_TEST(test_warmup_blank_arguments_are_rejected);
+ zero_bounce_set_http_perform_for_test(NULL);
+}
diff --git a/test/ZeroBounceTest.c b/test/ZeroBounceTest.c
index 54d490a..8c3efad 100644
--- a/test/ZeroBounceTest.c
+++ b/test/ZeroBounceTest.c
@@ -6,6 +6,7 @@
#include "ZeroBounce/ZeroBounce.h"
void offline_run_tests(void);
+void warmup_run_tests(void);
DEFINE_FFF_GLOBALS;
@@ -1175,6 +1176,7 @@ int main(void)
RUN_TEST(test_search_domain_status_serialize_functions);
offline_run_tests();
+ warmup_run_tests();
return UNITY_END();
}
\ No newline at end of file
From c6adeaf9b8570861b69f6b49bbee18f10253495f Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sat, 24 Oct 2026 11:29:04 +0400
Subject: [PATCH 15/19] test: add an opt-in live API suite
live/live_api.c is built only with -DZEROBOUNCE_BUILD_LIVE_TESTS=ON, so
`cmake --build build` and CI never compile it and the default test run never
touches the network. Keys come from the environment only:
ZEROBOUNCE_API_KEY the validation API (skipped without it)
ZEROBOUNCE_WARMUP_API_KEY the Warmup API (skipped without it)
ZEROBOUNCE_LIVE_SPEND=1 calls that spend credits
ZEROBOUNCE_LIVE_CHANGES=1 reversible account changes, undone in the
same run even when a check fails
By default it only makes free calls: sandbox addresses, reads, and requests
expected to fail that still prove the API accepts the payload. The free tier
checks that the credit balance did not move.
Never called, because the API cannot undo them: creating an Inbox Placement
test, setting the Inbox Placement webhook, and ZB managed Warmup onboarding.
Co-Authored-By: Claude Opus 5
---
.gitignore | 3 +
CMakeLists.txt | 10 +
live/live_api.c | 745 ++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 758 insertions(+)
create mode 100644 live/live_api.c
diff --git a/.gitignore b/.gitignore
index 416d8ff..6fed494 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,7 @@
# CMake / build
build/
+build-live/
cmake-build-*/
CMakeFiles/
CMakeCache.txt
@@ -57,6 +58,8 @@ _deps/
# Test artifacts
downloads/
test/downloads/
+# written and removed again by the live suite's bulk round trip
+zb-sdk-live.csv
# Misc
.DS_Store
diff --git a/CMakeLists.txt b/CMakeLists.txt
index b0e7956..6cdb5be 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -60,3 +60,13 @@ enable_testing()
add_executable(ZeroBounceTests test/ZeroBounceTest.c test/OfflineApiTest.c test/OfflineWarmupTest.c)
target_link_libraries(ZeroBounceTests PRIVATE ${PROJECT_NAME} unity)
target_include_directories(ZeroBounceTests PRIVATE ${fff_SOURCE_DIR})
+
+# The live suite calls the real API and spends credits, so it is opt-in and
+# never built by a plain `cmake --build build`:
+# cmake -S . -B build-live -DZEROBOUNCE_BUILD_LIVE_TESTS=ON
+# cmake --build build-live --target ZeroBounceLiveApi
+option(ZEROBOUNCE_BUILD_LIVE_TESTS "Build the opt-in live API suite" OFF)
+if(ZEROBOUNCE_BUILD_LIVE_TESTS)
+ add_executable(ZeroBounceLiveApi live/live_api.c)
+ target_link_libraries(ZeroBounceLiveApi PRIVATE ${PROJECT_NAME})
+endif()
diff --git a/live/live_api.c b/live/live_api.c
new file mode 100644
index 0000000..8b9eb9b
--- /dev/null
+++ b/live/live_api.c
@@ -0,0 +1,745 @@
+/*
+ * Opt-in live API suite: calls the SDK against the real API to check payloads
+ * and response shapes.
+ *
+ * It is its own executable, built only when ZEROBOUNCE_BUILD_LIVE_TESTS=ON, so
+ * `cmake --build build` and CI never compile it and `ctest` never runs it:
+ *
+ * cmake -S . -B build-live -DZEROBOUNCE_BUILD_LIVE_TESTS=ON
+ * cmake --build build-live --target ZeroBounceLiveApi
+ * export ZEROBOUNCE_API_KEY=... # validation API (skipped without it)
+ * export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API (skipped without it)
+ * ./build-live/bin/ZeroBounceLiveApi
+ *
+ * By default only free calls run: sandbox addresses (no credits), reads, and
+ * requests expected to fail (unknown file ids, S3/SFTP without an integration)
+ * that still prove the API accepts the payload. The free tier checks that the
+ * credit balance did not change. Two flags opt in to more:
+ *
+ * ZEROBOUNCE_LIVE_SPEND=1 spends credits: scoring, activity, email
+ * finder, and a bulk round trip whose job is
+ * deleted again
+ * ZEROBOUNCE_LIVE_CHANGES=1 reversible account changes, each undone in the
+ * same run even when a check fails: a test
+ * filter, and a self-managed Warmup sending
+ * account
+ *
+ * Never called, because the API cannot undo them: creating an Inbox Placement
+ * test (it cannot be deleted while running), setting the Inbox Placement
+ * webhook (it cannot be cleared), and ZB managed Warmup onboarding (it starts
+ * mailbox provisioning).
+ *
+ * No key is ever written to a file or printed. Keys come from the environment.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "ZeroBounce/ZeroBounce.h"
+#include "ZeroBounce/ZeroBounceWarmup.h"
+
+/* Well formed but matching no file: a malformed id can be Cloudflare blocked
+ instead of answered. */
+#define UNKNOWN_ID "0d1b7a4e-1111-2222-3333-444455556666"
+#define CALLBACK "https://example.com/zerobounce-sdk-live-test"
+#define SANDBOX "valid@example.com"
+
+static int passed;
+static int failed;
+static int skipped;
+static char failures[64][512];
+static int failure_count;
+
+static const char *api_key;
+static const char *warmup_key;
+static bool spend_flag;
+static bool changes_flag;
+
+static ZeroBounce *zb;
+static ZeroBounceWarmup *zbw;
+
+/* What the last callback saw. */
+static bool last_ok;
+static char last_error[1024];
+static long last_status;
+static char last_raw[65536];
+
+static const char *env_or_empty(const char *name) {
+ const char *value = getenv(name);
+ return value ? value : "";
+}
+
+static void redact(char *text) {
+ const char *secrets[2];
+ int i;
+ secrets[0] = api_key;
+ secrets[1] = warmup_key;
+ for (i = 0; i < 2; i++) {
+ const char *secret = secrets[i];
+ char *at;
+ size_t len;
+ if (!secret || !secret[0]) {
+ continue;
+ }
+ len = strlen(secret);
+ while ((at = strstr(text, secret)) != NULL) {
+ memmove(at + 3, at + len, strlen(at + len) + 1);
+ memcpy(at, "***", 3);
+ }
+ }
+}
+
+static void reset_result(void) {
+ last_ok = false;
+ last_status = 0;
+ last_error[0] = '\0';
+ last_raw[0] = '\0';
+}
+
+static void on_error(ZBErrorResponse error) {
+ size_t i;
+ size_t used = 0;
+ last_ok = false;
+ last_status = error.status_code;
+ last_error[0] = '\0';
+ for (i = 0; i < error.errors.size; i++) {
+ const char *part = error.errors.data[i] ? error.errors.data[i] : "";
+ int written = snprintf(last_error + used, sizeof(last_error) - used,
+ "%s%s", used ? " " : "", part);
+ if (written < 0 || (size_t)written >= sizeof(last_error) - used) {
+ break;
+ }
+ used += (size_t)written;
+ }
+ redact(last_error);
+}
+
+static void on_raw(const char *json_response) {
+ last_ok = true;
+ snprintf(last_raw, sizeof(last_raw), "%s", json_response ? json_response : "");
+}
+
+static void on_credits(ZBCreditsResponse response) { last_ok = true; (void)response; }
+static void on_api_usage(ZBGetApiUsageResponse response) { last_ok = true; (void)response; }
+static void on_validate(ZBValidateResponse response) { last_ok = true; (void)response; }
+static void on_score(ZBScoreEmailResponse response) { last_ok = true; (void)response; }
+static void on_activity(ZBActivityDataResponse response) { last_ok = true; (void)response; }
+static void on_find_email(ZBFindEmailResponse response) { last_ok = true; (void)response; }
+static void on_domain(ZBDomainSearchResponse response) { last_ok = true; (void)response; }
+static void on_delete(ZBDeleteFileResponse response) { last_ok = true; (void)response; }
+static void on_get_file(ZBGetFileResponse response) { last_ok = true; (void)response; }
+
+static long long credits_before = -2;
+static long long credits_after = -2;
+static void on_credits_before(ZBCreditsResponse response) {
+ last_ok = true;
+ credits_before = response.credits;
+}
+static void on_credits_after(ZBCreditsResponse response) {
+ last_ok = true;
+ credits_after = response.credits;
+}
+
+static char batch_account_error[512];
+static void on_batch(ZBValidateBatchResponse response) {
+ size_t i;
+ last_ok = true;
+ batch_account_error[0] = '\0';
+ for (i = 0; i < response.errors.size; i++) {
+ const char *address = response.errors.data[i].email_address;
+ if (address && strcasecmp(address, "all") == 0) {
+ snprintf(batch_account_error, sizeof(batch_account_error), "%s",
+ response.errors.data[i].error ? response.errors.data[i].error : "unknown");
+ }
+ }
+}
+
+static char send_file_id[128];
+static void on_send_file(ZBSendFileResponse response) {
+ last_ok = true;
+ snprintf(send_file_id, sizeof(send_file_id), "%s", response.file_id ? response.file_id : "");
+}
+
+static char status_file_id[128];
+static void on_file_status(ZBFileStatusResponse response) {
+ last_ok = true;
+ snprintf(status_file_id, sizeof(status_file_id), "%s", response.file_id ? response.file_id : "");
+}
+
+static void pass(const char *name) {
+ passed++;
+ printf(" + %s\n", name);
+ fflush(stdout);
+}
+
+static void fail(const char *name, const char *problem) {
+ char safe[512];
+ failed++;
+ snprintf(safe, sizeof(safe), "%s", problem);
+ redact(safe);
+ if (failure_count < (int)(sizeof(failures) / sizeof(failures[0]))) {
+ snprintf(failures[failure_count++], sizeof(failures[0]), "%s: %s", name, safe);
+ }
+ printf(" X %s\n %s\n", name, safe);
+ fflush(stdout);
+}
+
+static void skip(const char *name, const char *reason) {
+ skipped++;
+ printf(" - SKIP %s (%s)\n", name, reason);
+ fflush(stdout);
+}
+
+/* The common shape: the call ran, and either it succeeded or it did not. */
+static void check_ok(const char *name) {
+ if (last_ok) {
+ pass(name);
+ } else {
+ fail(name, last_error[0] ? last_error : "no response");
+ }
+}
+
+static void iso_days_ago(int days, char *out, size_t out_len) {
+ time_t now = time(NULL) - (time_t)days * 24 * 3600;
+ struct tm value;
+ localtime_r(&now, &value);
+ strftime(out, out_len, "%Y-%m-%d", &value);
+}
+
+static struct tm days_ago(int days) {
+ time_t now = time(NULL) - (time_t)days * 24 * 3600;
+ struct tm value;
+ localtime_r(&now, &value);
+ return value;
+}
+
+/* -- validation API, free -------------------------------------------------- */
+
+static void validation_free(void) {
+ ZBValidateOptions options;
+ EmailToValidateVector batch;
+ ZBEmailToValidate row = {SANDBOX, ""};
+ ZBBulkFileJobOptions job;
+ ZBListInboxPlacementTestsOptions listing;
+
+ printf("Validation API (free calls)\n");
+
+ reset_result();
+ get_credits(zb, on_credits_before, on_error);
+ check_ok("getCredits returns a balance");
+
+ reset_result();
+ {
+ struct tm from = days_ago(7);
+ struct tm to = days_ago(0);
+ get_api_usage(zb, from, to, on_api_usage, on_error);
+ }
+ check_ok("getApiUsage parses the documented date formats");
+
+ reset_result();
+ validate_email(zb, SANDBOX, "", on_validate, on_error);
+ check_ok("validate on a sandbox address spends nothing");
+
+ /* No billable add-on here: activity_data, verify_plus and domain_info are
+ charged even on a sandbox address, so they live in the SPEND tier. */
+ reset_result();
+ options = new_validate_options();
+ options.use_post = true;
+ options.timeout = 30;
+ validate_email_with_options(zb, SANDBOX, "", &options, on_validate, on_error);
+ check_ok("validate works over POST as well as GET");
+
+ reset_result();
+ options = new_validate_options();
+ options.timeout = 30;
+ batch = email_to_validate_vector_init();
+ email_to_validate_vector_append(&batch, row);
+ validate_email_batch_with_options(zb, batch, &options, on_batch, on_error);
+ email_to_validate_vector_free(&batch);
+ if (!last_ok) {
+ fail("LIVE: validateBatch accepts timeout as a string",
+ last_error[0] ? last_error : "no response");
+ } else if (batch_account_error[0]) {
+ fail("LIVE: validateBatch accepts timeout as a string", batch_account_error);
+ } else {
+ pass("LIVE: validateBatch accepts timeout as a string");
+ }
+
+ reset_result();
+ file_status(zb, UNKNOWN_ID, on_file_status, on_error);
+ if (last_ok) {
+ fail("an unknown file id is a short, clean error", "fileStatus unexpectedly succeeded");
+ } else if (strlen(last_error) >= 500 || strstr(last_error, "= 500) {
+ fail("listS3files answers (empty without an S3 integration)", "message too long");
+ } else {
+ pass("listS3files answers (empty without an S3 integration)");
+ }
+
+ reset_result();
+ job = new_bulk_file_job_options();
+ job.file_name = "f.csv";
+ job.has_header_row = true;
+ email_finder_s3_file(zb, &job, on_send_file, on_error);
+ if (last_ok) {
+ fail("LIVE: S3/SFTP jobs reject a payload without return_url before sending",
+ "a payload without return_url was accepted");
+ } else if (!strstr(last_error, "return_url")) {
+ fail("LIVE: S3/SFTP jobs reject a payload without return_url before sending", last_error);
+ } else {
+ pass("LIVE: S3/SFTP jobs reject a payload without return_url before sending");
+ }
+
+ /* Without an S3 integration this fails on the integration, not the payload. */
+ reset_result();
+ job.return_url = CALLBACK;
+ email_finder_s3_file(zb, &job, on_send_file, on_error);
+ if (!last_ok && (strstr(last_error, "return URL is not permitted") ||
+ strstr(last_error, "This field is required"))) {
+ fail("LIVE: a complete S3 job payload is accepted by the API", last_error);
+ } else {
+ pass("LIVE: a complete S3 job payload is accepted by the API");
+ }
+
+ reset_result();
+ list_filters(zb, false, on_raw, on_error);
+ check_ok("filters list");
+
+ /* LIVE: an api_key parameter is rejected as unauthorized here. */
+ reset_result();
+ get_inbox_placement_providers(zb, on_raw, on_error);
+ check_ok("LIVE: the Inbox Placement Tester accepts a Bearer key");
+
+ reset_result();
+ get_inbox_placement_test_types(zb, on_raw, on_error);
+ check_ok("inbox placement test types");
+
+ /* LIVE: the listing is GET /tests (plural); /test is the single-test path
+ and answers 'Method "GET" not allowed'. */
+ reset_result();
+ listing = new_list_inbox_placement_tests_options();
+ listing.page = 1;
+ listing.page_size = 10;
+ list_inbox_placement_tests(zb, &listing, on_raw, on_error);
+ if (!last_ok) {
+ fail("LIVE: inbox placement tests are listed from /tests, and the webhook reads back",
+ last_error[0] ? last_error : "no response");
+ } else {
+ reset_result();
+ get_inbox_placement_webhook(zb, on_raw, on_error);
+ check_ok("LIVE: inbox placement tests are listed from /tests, and the webhook reads back");
+ }
+
+ reset_result();
+ get_list_evaluator_result(zb, UNKNOWN_ID, on_raw, on_error);
+ if (last_ok) {
+ fail("list evaluator result for an unknown id fails cleanly",
+ "an unknown evaluation id unexpectedly succeeded");
+ } else if (strlen(last_error) >= 500) {
+ fail("list evaluator result for an unknown id fails cleanly", "message too long");
+ } else {
+ pass("list evaluator result for an unknown id fails cleanly");
+ }
+
+ /* LIVE: the file id is a form field. Appended to the path it is a 404. */
+ reset_result();
+ send_evaluated_file(zb, UNKNOWN_ID, on_send_file, on_error);
+ if (last_ok) {
+ fail("LIVE: sendEvaluatedFile posts the file id as a form field",
+ "an unknown evaluation id unexpectedly succeeded");
+ } else if (strstr(last_error, "not allowed")) {
+ fail("LIVE: sendEvaluatedFile posts the file id as a form field", last_error);
+ } else {
+ pass("LIVE: sendEvaluatedFile posts the file id as a form field");
+ }
+
+ reset_result();
+ get_credits(zb, on_credits_after, on_error);
+ if (!last_ok) {
+ fail("the free calls did not change the credit balance",
+ last_error[0] ? last_error : "no response");
+ } else if (credits_after != credits_before) {
+ char message[128];
+ snprintf(message, sizeof(message), "credits went from %lld to %lld",
+ credits_before, credits_after);
+ fail("the free calls did not change the credit balance", message);
+ } else {
+ pass("the free calls did not change the credit balance");
+ }
+}
+
+/* -- validation API, spends credits ---------------------------------------- */
+
+static char bulk_file_id[128];
+
+static void validation_spend(void) {
+ ZBValidateOptions options;
+ SendFileOptions send_options;
+
+ printf("\nValidation API (ZEROBOUNCE_LIVE_SPEND=1)\n");
+
+ /* LIVE: activity_data, verify_plus and domain_info are billable add-ons and
+ are charged even for a sandbox address, so this is not free. */
+ reset_result();
+ options = new_validate_options();
+ options.timeout = 30;
+ options.activity_data = 1;
+ options.verify_plus = 1;
+ options.domain_info = 1;
+ options.greylist_processing = 1;
+ options.callback_url = CALLBACK;
+ validate_email_with_options(zb, SANDBOX, "", &options, on_validate, on_error);
+ check_ok("validate accepts every documented option");
+
+ reset_result();
+ score_email(zb, SANDBOX, on_score, on_error);
+ check_ok("scoreEmail");
+
+ reset_result();
+ score_email_post(zb, SANDBOX, on_score, on_error);
+ check_ok("scoreEmail over POST");
+
+ reset_result();
+ get_activity_data(zb, SANDBOX, on_activity, on_error);
+ check_ok("getActivityData");
+
+ reset_result();
+ find_email_by_domain_first_last_name(zb, "example.com", "John", "Doe", on_find_email, on_error);
+ check_ok("email finder");
+
+ reset_result();
+ search_domain_by_domain(zb, "example.com", on_domain, on_error);
+ check_ok("domain search");
+
+ reset_result();
+ bulk_file_id[0] = '\0';
+ send_file_id[0] = '\0';
+ {
+ const char *path = "zb-sdk-live.csv";
+ FILE *out = fopen(path, "w");
+ if (!out) {
+ fail("bulk validation round trip: upload, status, delete",
+ "could not write the upload file");
+ } else {
+ fputs("email\nvalid@example.com\ninvalid@example.com\n", out);
+ fclose(out);
+ send_options = new_send_file_options();
+ send_options.has_header_row = true;
+ send_file(zb, (char *)path, 1, send_options, on_send_file, on_error);
+ remove(path);
+ if (!last_ok) {
+ fail("bulk validation round trip: upload, status, delete",
+ last_error[0] ? last_error : "no response");
+ } else if (!send_file_id[0]) {
+ fail("bulk validation round trip: upload, status, delete",
+ "no file_id in the upload response");
+ } else {
+ snprintf(bulk_file_id, sizeof(bulk_file_id), "%s", send_file_id);
+ reset_result();
+ status_file_id[0] = '\0';
+ file_status(zb, bulk_file_id, on_file_status, on_error);
+ if (!last_ok) {
+ fail("bulk validation round trip: upload, status, delete",
+ last_error[0] ? last_error : "no response");
+ } else if (strcmp(status_file_id, bulk_file_id) != 0) {
+ fail("bulk validation round trip: upload, status, delete",
+ "status did not echo the file id");
+ } else {
+ pass("bulk validation round trip: upload, status, delete");
+ }
+ }
+ }
+ }
+
+ if (!bulk_file_id[0]) {
+ skip("the uploaded validation job is deleted again",
+ "upload did not produce a file id");
+ return;
+ }
+ /* LIVE: a just-accepted job answers "File cannot be found" for a short
+ while, so the delete is retried rather than leaving it behind. */
+ {
+ int attempt;
+ char last[512] = "";
+ for (attempt = 0; attempt < 12; attempt++) {
+ reset_result();
+ delete_file(zb, bulk_file_id, on_delete, on_error);
+ if (last_ok) {
+ pass("the uploaded validation job is deleted again");
+ return;
+ }
+ snprintf(last, sizeof(last), "%s", last_error);
+ sleep(10);
+ }
+ {
+ char message[640];
+ snprintf(message, sizeof(message), "could not delete %s after retrying: %s",
+ bulk_file_id, last);
+ fail("the uploaded validation job is deleted again", message);
+ }
+ }
+}
+
+/* -- validation API, reversible account changes ---------------------------- */
+
+static void validation_changes(void) {
+ char value[128];
+ bool added;
+
+ printf("\nValidation API (ZEROBOUNCE_LIVE_CHANGES=1)\n");
+ snprintf(value, sizeof(value), "zb-sdk-live-%lld.example.com", (long long)time(NULL));
+
+ reset_result();
+ add_filter(zb, "allow", "domain", value, on_raw, on_error);
+ added = last_ok;
+ check_ok("addFilter creates a reversible allow rule");
+
+ if (!added) {
+ skip("the test filter is removed again", "filter was not created");
+ return;
+ }
+ reset_result();
+ delete_filter(zb, "allow", "domain", value, on_raw, on_error);
+ check_ok("the test filter is removed again");
+}
+
+/* -- Warmup API ------------------------------------------------------------ */
+
+/*
+ * The from-address of the first sending account this team has, or "": the
+ * statistics endpoints answer "Sending account ... was not found" for anything
+ * else.
+ */
+static void first_sending_address(char *out, size_t out_len) {
+ const char *at;
+ const char *value;
+ const char *end;
+
+ out[0] = '\0';
+ reset_result();
+ warmup_list_sending_accounts(zbw, on_raw, on_error);
+ if (!last_ok) {
+ return;
+ }
+ at = strstr(last_raw, "\"from_address\"");
+ if (!at) {
+ at = strstr(last_raw, "\"c_from\"");
+ }
+ if (!at) {
+ return;
+ }
+ value = strchr(at + 1, ':');
+ if (!value) {
+ return;
+ }
+ value = strchr(value, '"');
+ if (!value) {
+ return;
+ }
+ value++;
+ end = strchr(value, '"');
+ if (!end || end == value || (size_t)(end - value) >= out_len) {
+ return;
+ }
+ memcpy(out, value, (size_t)(end - value));
+ out[end - value] = '\0';
+}
+
+static void warmup_free_reads(void) {
+ char known[256];
+ char start[16];
+ char end[16];
+
+ printf("\nWarmup API (reads)\n");
+
+ reset_result();
+ warmup_list_sending_accounts(zbw, on_raw, on_error);
+ check_ok("listSendingAccounts");
+
+ reset_result();
+ warmup_list_from_addresses(zbw, NULL, on_raw, on_error);
+ check_ok("listFromAddresses");
+
+ reset_result();
+ warmup_get_engagement_rules(zbw, on_raw, on_error);
+ check_ok("getEngagementRules");
+
+ reset_result();
+ warmup_get_seeds(zbw, on_raw, on_error);
+ check_ok("getSeeds");
+
+ reset_result();
+ warmup_get_seed_count(zbw, on_raw, on_error);
+ check_ok("getSeedCount");
+
+ reset_result();
+ warmup_get_progress_today(zbw, on_raw, on_error);
+ check_ok("getProgressToday");
+
+ reset_result();
+ warmup_get_sent_today(zbw, on_raw, on_error);
+ check_ok("getSentToday");
+
+ reset_result();
+ warmup_get_emails_per_day(zbw, on_raw, on_error);
+ check_ok("getEmailsPerDay");
+
+ reset_result();
+ warmup_download_seeds(zbw, NULL, on_raw, on_error);
+ if (!last_ok) {
+ fail("downloadSeeds returns CSV text", last_error[0] ? last_error : "no response");
+ } else if (!last_raw[0]) {
+ fail("downloadSeeds returns CSV text", "empty CSV body");
+ } else if (last_raw[0] == '{') {
+ fail("downloadSeeds returns CSV text", "got JSON instead of CSV");
+ } else {
+ pass("downloadSeeds returns CSV text");
+ }
+
+ /* LIVE: a bare domain is answered with "Please provide a valid email
+ address", although the docs describe a domain. */
+ reset_result();
+ warmup_check_domain_dmarc(zbw, "example.com", NULL, on_raw, on_error);
+ if (last_ok) {
+ fail("LIVE: checkDomainDmarc rejects a bare domain before sending",
+ "a bare domain unexpectedly succeeded");
+ } else if (!strstr(last_error, "email address")) {
+ fail("LIVE: checkDomainDmarc rejects a bare domain before sending", last_error);
+ } else {
+ pass("LIVE: checkDomainDmarc rejects a bare domain before sending");
+ }
+
+ reset_result();
+ warmup_check_domain_dmarc(zbw, "sender@example.com", NULL, on_raw, on_error);
+ check_ok("LIVE: checkDomainDmarc accepts an email address");
+
+ first_sending_address(known, sizeof(known));
+ if (!known[0]) {
+ const char *reason = "this team has no sending account";
+ skip("statistics for a registered from-address", reason);
+ skip("statistics per day over a short range", reason);
+ skip("detailed statistics and the CSV download for that address", reason);
+ skip("from-address statistics", reason);
+ } else {
+ reset_result();
+ warmup_get_statistics(zbw, known, on_raw, on_error);
+ check_ok("statistics for a registered from-address");
+
+ reset_result();
+ iso_days_ago(6, start, sizeof(start));
+ iso_days_ago(0, end, sizeof(end));
+ warmup_get_statistics_per_day(zbw, known, start, end, on_raw, on_error);
+ check_ok("statistics per day over a short range");
+
+ reset_result();
+ warmup_get_statistics_detailed(zbw, known, on_raw, on_error);
+ if (!last_ok) {
+ fail("detailed statistics and the CSV download for that address",
+ last_error[0] ? last_error : "no response");
+ } else {
+ reset_result();
+ warmup_download_statistics(zbw, known, NULL, on_raw, on_error);
+ check_ok("detailed statistics and the CSV download for that address");
+ }
+
+ reset_result();
+ warmup_get_from_address_statistics(zbw, known, on_raw, on_error);
+ check_ok("from-address statistics");
+ }
+
+ reset_result();
+ warmup_get_custom_engagement(zbw, "no-such-address@example.com", on_raw, on_error);
+ if (last_ok) {
+ pass("an unknown Warmup resource fails with a status code");
+ } else if (last_status == 0) {
+ fail("an unknown Warmup resource fails with a status code", "no status code on the error");
+ } else if (warmup_key[0] && strstr(last_error, warmup_key)) {
+ fail("an unknown Warmup resource fails with a status code",
+ "the key leaked into the message");
+ } else {
+ pass("an unknown Warmup resource fails with a status code");
+ }
+}
+
+static void warmup_changes(void) {
+ char address[128];
+ ZBWarmupAddSendingAccountOptions options;
+ bool added;
+
+ printf("\nWarmup API (ZEROBOUNCE_LIVE_CHANGES=1)\n");
+ snprintf(address, sizeof(address), "zb-sdk-live-%lld@example.com", (long long)time(NULL));
+
+ reset_result();
+ options = new_warmup_add_sending_account_options();
+ options.engagement_rule_id = 0;
+ options.ignore_conflict = 1;
+ warmup_add_sending_account(zbw, address, ZB_WARMUP_SELF_MANAGED, &options, on_raw, on_error);
+ added = last_ok;
+ check_ok("a self-managed sending account can be added");
+
+ if (!added) {
+ skip("the test sending account is removed again", "account was not created");
+ return;
+ }
+ reset_result();
+ warmup_delete_sending_account(zbw, address, on_raw, on_error);
+ check_ok("the test sending account is removed again");
+}
+
+int main(void) {
+ int i;
+
+ api_key = env_or_empty("ZEROBOUNCE_API_KEY");
+ warmup_key = env_or_empty("ZEROBOUNCE_WARMUP_API_KEY");
+ spend_flag = strcmp(env_or_empty("ZEROBOUNCE_LIVE_SPEND"), "1") == 0;
+ changes_flag = strcmp(env_or_empty("ZEROBOUNCE_LIVE_CHANGES"), "1") == 0;
+
+ printf("ZeroBounce live API suite\n");
+ printf(" validation key: %s\n", api_key[0] ? "set" : "MISSING");
+ printf(" warmup key: %s\n", warmup_key[0] ? "set" : "MISSING");
+ printf(" flags: SPEND=%d CHANGES=%d\n\n", spend_flag ? 1 : 0, changes_flag ? 1 : 0);
+ fflush(stdout);
+
+ if (api_key[0]) {
+ zb = new_zero_bounce_instance();
+ zero_bounce_initialize(zb, api_key);
+ validation_free();
+ if (spend_flag) {
+ validation_spend();
+ }
+ if (changes_flag) {
+ validation_changes();
+ }
+ }
+
+ if (warmup_key[0]) {
+ zbw = new_zero_bounce_warmup_instance();
+ zero_bounce_warmup_initialize(zbw, warmup_key);
+ warmup_free_reads();
+ if (changes_flag) {
+ warmup_changes();
+ }
+ zero_bounce_warmup_free(zbw);
+ }
+
+ printf("\n%d passed, %d failed, %d skipped\n", passed, failed, skipped);
+ if (failure_count) {
+ printf("\nFailures:\n");
+ for (i = 0; i < failure_count; i++) {
+ printf(" - %s\n", failures[i]);
+ }
+ }
+ return failed == 0 ? 0 : 1;
+}
From 564458bc7f791c06bfbec4f3a337b4ae69fb0193 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sat, 24 Oct 2026 11:29:04 +0400
Subject: [PATCH 16/19] feat: stream large result-file downloads straight to
disk
get_file buffers the whole body in memory before writing it. For a list too
large to hold, get_file_streaming and scoring_get_file_streaming write it out
as it arrives.
They are opt-in rather than the default because a streamed body cannot be
inspected as a whole, and getfile answers an unknown or deleted file id with a
JSON error rather than a file. The first 4 KB are held back and checked, so an
error still reaches the error callback and no partial file is left behind; the
offline suite drives the same probe-then-stream path through the test hook.
Co-Authored-By: Claude Opus 5
---
include/ZeroBounce/ZeroBounce.h | 35 ++++
src/ZeroBounce.c | 280 +++++++++++++++++++++++++++++++-
test/OfflineApiTest.c | 45 +++++
3 files changed, 359 insertions(+), 1 deletion(-)
diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h
index 6375939..4783017 100644
--- a/include/ZeroBounce/ZeroBounce.h
+++ b/include/ZeroBounce/ZeroBounce.h
@@ -498,6 +498,41 @@ void get_file(
OnErrorCallback error_callback
);
+/**
+ * @brief As get_file, but written straight to disk as it arrives instead of
+ * being held in memory first — use it for a download too large to buffer.
+ *
+ * It is opt-in because a streamed body cannot be inspected as a whole, and
+ * getfile answers an unknown or deleted file id with a JSON error rather than
+ * a file. The first 4 KB are held back and checked, so an error still reaches
+ * the error callback and no partial file is left behind.
+ *
+ * @param zb ZeroBounce pointer
+ * @param file_id the returned file ID when calling send file API
+ * @param local_download_path the path to which to download the file
+ * @param options optional v2 query parameters, or NULL
+ * @param success_callback success callback
+ * @param error_callback error callback
+ */
+void get_file_streaming(
+ ZeroBounce *zb,
+ char* file_id,
+ char* local_download_path,
+ const GetFileOptions *options,
+ OnSuccessCallbackGetFile success_callback,
+ OnErrorCallback error_callback
+);
+
+/** @brief The AI scoring counterpart of get_file_streaming. */
+void scoring_get_file_streaming(
+ ZeroBounce *zb,
+ char* file_id,
+ char* local_download_path,
+ const GetFileOptions *options,
+ OnSuccessCallbackGetFile success_callback,
+ OnErrorCallback error_callback
+);
+
/**
* @brief Bulk get file with optional v2 query parameters (download_type, activity_data for validation only).
*/
diff --git a/src/ZeroBounce.c b/src/ZeroBounce.c
index f39b306..fa92e15 100644
--- a/src/ZeroBounce.c
+++ b/src/ZeroBounce.c
@@ -208,7 +208,7 @@ void set_write_callback(CURL* curl, memory* response_data) {
/* A redirect could send the api_key to another host, so one is reported
rather than followed, and only https is ever dialled. */
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
-#ifdef CURLOPT_PROTOCOLS_STR
+#if LIBCURL_VERSION_NUM >= 0x075500 /* 7.85.0 */
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
#else
curl_easy_setopt(curl, CURLOPT_PROTOCOLS, (long)CURLPROTO_HTTPS);
@@ -2040,6 +2040,284 @@ void file_status(
}
+
+/*
+ * A streaming getfile download: the body goes straight to disk as it arrives
+ * instead of being held in memory first.
+ *
+ * It is opt-in because a streamed body cannot be inspected as a whole, and
+ * getfile answers an unknown or deleted file id with a JSON error rather than
+ * a file. The first chunk is held back and checked; only once it is clearly
+ * not an error does the file get created and the rest stream through.
+ */
+#define ZB_STREAM_PROBE_BYTES 4096
+
+typedef struct {
+ FILE *file;
+ const char *path;
+ char probe[ZB_STREAM_PROBE_BYTES + 1];
+ size_t probe_size;
+ bool decided;
+ bool rejected;
+ bool open_failed;
+} ZbDownloadStream;
+
+static bool zb_stream_open(ZbDownloadStream *stream) {
+ struct stat sb;
+ char *dir_path;
+ char *end;
+#ifdef _WIN32
+ const char path_separator = '\\';
+#else
+ const char path_separator = '/';
+#endif
+
+ if (stat(stream->path, &sb) == 0 && S_ISDIR(sb.st_mode)) {
+ return false;
+ }
+ dir_path = strdup(stream->path);
+ if (dir_path) {
+ end = strrchr(dir_path, path_separator);
+ if (end) {
+ *end = '\0';
+ mkdir(dir_path, 0777);
+ }
+ free(dir_path);
+ }
+ stream->file = fopen(stream->path, "wb");
+ return stream->file != NULL;
+}
+
+static size_t zb_stream_write(void *contents, size_t size, size_t nmemb, void *userp) {
+ ZbDownloadStream *stream = (ZbDownloadStream *)userp;
+ size_t total = size * nmemb;
+
+ if (stream->rejected || stream->open_failed) {
+ /* Keep draining so libcurl reports the HTTP status, not a write error. */
+ return total;
+ }
+ if (!stream->decided) {
+ size_t room = ZB_STREAM_PROBE_BYTES - stream->probe_size;
+ size_t take = total < room ? total : room;
+ memcpy(stream->probe + stream->probe_size, contents, take);
+ stream->probe_size += take;
+ stream->probe[stream->probe_size] = '\0';
+ if (stream->probe_size < ZB_STREAM_PROBE_BYTES && take == total) {
+ return total;
+ }
+ /* Enough to tell a file from an error page. */
+ stream->decided = true;
+ if (zb_body_looks_like_html(stream->probe) || zb_success_means_failure(stream->probe)) {
+ stream->rejected = true;
+ return total;
+ }
+ if (!zb_stream_open(stream)) {
+ stream->open_failed = true;
+ return total;
+ }
+ fwrite(stream->probe, 1, stream->probe_size, stream->file);
+ if (take < total) {
+ fwrite((const char *)contents + take, 1, total - take, stream->file);
+ }
+ return total;
+ }
+ fwrite(contents, 1, total, stream->file);
+ return total;
+}
+
+/* Flushes whatever the probe still holds when the body was shorter than it. */
+static void zb_stream_finish(ZbDownloadStream *stream) {
+ if (stream->decided || stream->rejected || stream->open_failed) {
+ return;
+ }
+ stream->decided = true;
+ if (zb_body_looks_like_html(stream->probe) || zb_success_means_failure(stream->probe) ||
+ stream->probe_size == 0) {
+ stream->rejected = true;
+ return;
+ }
+ if (!zb_stream_open(stream)) {
+ stream->open_failed = true;
+ return;
+ }
+ fwrite(stream->probe, 1, stream->probe_size, stream->file);
+}
+
+static void get_file_streaming_internal(
+ ZeroBounce *zb,
+ bool scoring,
+ char *file_id,
+ char *local_download_path,
+ const GetFileOptions *options,
+ OnSuccessCallbackGetFile success_callback,
+ OnErrorCallback error_callback
+) {
+ GetFileOptions default_opts = new_get_file_options();
+ ZbDownloadStream stream;
+ char *url_path;
+ long http_code = 0;
+ CURL *curl;
+ CURLcode res;
+ struct curl_slist *headers = NULL;
+
+ if (zero_bounce_invalid_api_key(zb, error_callback)) {
+ return;
+ }
+ if (!local_download_path || !local_download_path[0]) {
+ if (error_callback) {
+ error_callback(parse_error("Empty parameter: local_download_path"));
+ }
+ return;
+ }
+ if (!options) {
+ options = &default_opts;
+ }
+ url_path = zb_build_getfile_url(zb, scoring, file_id, options);
+ if (!url_path) {
+ if (error_callback) {
+ error_callback(parse_error("Failed to build getfile URL"));
+ }
+ return;
+ }
+ zb_note_http_request("GET", url_path, NULL);
+ if (!zb_check_request_url(url_path, error_callback)) {
+ free(url_path);
+ return;
+ }
+
+ memset(&stream, 0, sizeof(stream));
+ stream.path = local_download_path;
+
+ if (s_http_perform) {
+ /* The offline suite drives the same probe-then-stream logic. */
+ memory buffer = {0};
+ buffer.max_bytes = ZB_MAX_GETFILE_BYTES;
+ if (!s_http_perform(&buffer, &http_code, NULL)) {
+ free(buffer.response);
+ free(url_path);
+ if (error_callback) {
+ error_callback(parse_error("Mock request failed"));
+ }
+ return;
+ }
+ if (buffer.response && buffer.size) {
+ zb_stream_write(buffer.response, 1, buffer.size, &stream);
+ }
+ free(buffer.response);
+ free(url_path);
+ zb_stream_finish(&stream);
+ if (stream.file) {
+ fclose(stream.file);
+ stream.file = NULL;
+ }
+ if (http_code > 299 || stream.rejected) {
+ remove(local_download_path);
+ zb_report_body_error(stream.probe, http_code, error_callback);
+ return;
+ }
+ if (stream.open_failed) {
+ if (error_callback) {
+ error_callback(parse_error("Invalid file path"));
+ }
+ return;
+ }
+ if (success_callback) {
+ ZBGetFileResponse response = new_zb_get_file_response();
+ response.success = true;
+ response.local_file_path = local_download_path;
+ success_callback(response);
+ }
+ return;
+ }
+
+ curl = curl_easy_init();
+ if (!curl) {
+ free(url_path);
+ if (error_callback) {
+ error_callback(parse_error("Failed to initialize libcurl"));
+ }
+ return;
+ }
+ curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "GET");
+ curl_easy_setopt(curl, CURLOPT_URL, url_path);
+ headers = curl_slist_append(headers, "Accept: application/json");
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, zb_stream_write);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&stream);
+ curl_easy_setopt(curl, CURLOPT_TIMEOUT, 120L);
+ curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
+ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
+#if LIBCURL_VERSION_NUM >= 0x075500 /* 7.85.0 */
+ curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
+#else
+ curl_easy_setopt(curl, CURLOPT_PROTOCOLS, (long)CURLPROTO_HTTPS);
+#endif
+
+ res = curl_easy_perform(curl);
+ zb_stream_finish(&stream);
+ if (stream.file) {
+ fclose(stream.file);
+ stream.file = NULL;
+ }
+ http_code = get_http_code(curl);
+ curl_easy_cleanup(curl);
+ curl_slist_free_all(headers);
+ free(url_path);
+
+ if (res != CURLE_OK) {
+ char message[300];
+ snprintf(message, sizeof(message),
+ "Failed to perform request. CURLcode: %d. Error: %s",
+ res, curl_easy_strerror(res));
+ if (error_callback) {
+ error_callback(parse_error(message));
+ }
+ remove(local_download_path);
+ return;
+ }
+ if (http_code > 299 || stream.rejected) {
+ remove(local_download_path);
+ zb_report_body_error(stream.probe, http_code, error_callback);
+ return;
+ }
+ if (stream.open_failed) {
+ if (error_callback) {
+ error_callback(parse_error("Invalid file path"));
+ }
+ return;
+ }
+ if (success_callback) {
+ ZBGetFileResponse response = new_zb_get_file_response();
+ response.success = true;
+ response.local_file_path = local_download_path;
+ success_callback(response);
+ }
+}
+
+void get_file_streaming(
+ ZeroBounce *zb,
+ char* file_id,
+ char* local_download_path,
+ const GetFileOptions *options,
+ OnSuccessCallbackGetFile success_callback,
+ OnErrorCallback error_callback
+) {
+ get_file_streaming_internal(zb, false, file_id, local_download_path, options,
+ success_callback, error_callback);
+}
+
+void scoring_get_file_streaming(
+ ZeroBounce *zb,
+ char* file_id,
+ char* local_download_path,
+ const GetFileOptions *options,
+ OnSuccessCallbackGetFile success_callback,
+ OnErrorCallback error_callback
+) {
+ get_file_streaming_internal(zb, true, file_id, local_download_path, options,
+ success_callback, error_callback);
+}
+
void get_file(
ZeroBounce *zb,
char* file_id,
diff --git a/test/OfflineApiTest.c b/test/OfflineApiTest.c
index 83ed41b..ebcbdda 100644
--- a/test/OfflineApiTest.c
+++ b/test/OfflineApiTest.c
@@ -1138,6 +1138,50 @@ void test_offline_redirect_is_not_followed(void) {
TEST_ASSERT_EQUAL_INT(302, (int)g_error_status);
}
+void test_offline_streaming_download(void) {
+ const char *path = "test/downloads/offline-stream.csv";
+ reset_offline();
+ /* The body goes straight to disk instead of being buffered first. */
+ ZeroBounce *zb = client_with("stream-key");
+
+ remove(path);
+ get_file_streaming(zb, "fid", (char *)path, NULL, on_get, on_error);
+ TEST_ASSERT_TRUE(g_success_called);
+ TEST_ASSERT_FALSE(g_error_called);
+ {
+ FILE *handle = fopen(path, "rb");
+ char first[32] = {0};
+ TEST_ASSERT_NOT_NULL(handle);
+ TEST_ASSERT_TRUE(fread(first, 1, sizeof(first) - 1, handle) > 0);
+ fclose(handle);
+ TEST_ASSERT_EQUAL_INT(0, strncmp(first, "email", 5));
+ }
+ remove(path);
+
+ /* getfile answers an unknown or deleted id with a JSON error rather than
+ a file, so the probe must catch it and leave nothing behind. */
+ reset_offline();
+ zb = client_with("success-false");
+ get_file_streaming(zb, "fid", (char *)path, NULL, on_get, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NOT_NULL(strstr(g_error_text, "File cannot be found"));
+ TEST_ASSERT_NULL(fopen(path, "rb"));
+
+ /* An HTML error page is caught the same way. */
+ reset_offline();
+ zb = client_with("html-200-stream");
+ get_file_streaming(zb, "fid", (char *)path, NULL, on_get, on_error);
+ TEST_ASSERT_TRUE(g_error_called);
+ TEST_ASSERT_NULL(strstr(g_error_text, "
Date: Sat, 24 Oct 2026 11:29:04 +0400
Subject: [PATCH 17/19] docs: document the Warmup API, the live corrections and
the live suite
README.md and documentation.md gain the Warmup API, the list evaluator
(including send_evaluated_file now taking a file id), and the streaming
download, and lose the two claims the live API contradicts: that the Inbox
Placement Tester takes the key on the query string, and that
validate_sftp_file's file_name is optional.
AGENTS.md gains the live-API-wins rule with the differences found so far, the
safety rails, how to build and run the live suite, and a release rule: run it
by hand with real keys before tagging, because the offline suite cannot catch
an API that has changed shape underneath the SDK.
Co-Authored-By: Claude Opus 5
---
AGENTS.md | 26 +++++++++++---
CHANGELOG.md | 15 ++++++--
README.md | 92 +++++++++++++++++++++++++++++++++++++++++++-----
documentation.md | 92 +++++++++++++++++++++++++++++++++++++++++++-----
4 files changed, 203 insertions(+), 22 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 78d0873..4e90313 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,9 +11,11 @@ Guidance for AI coding agents working in this repository.
- `include/ZeroBounce/`: public headers. `ZeroBounce.h` is the client.
- `src/ZeroBounce.c`: validation, bulk file, credits, activity, scoring, and the libcurl write callback.
- `src/Priority3.c`: bulk email finder, domain search, list evaluator, filters, the S3 / SFTP bulk jobs, and the Inbox Placement Tester. The six file jobs share `ZBBulkFileJobOptions` and `p3_bulk_root_file_job`.
-- `src/ZBErrorResponse.c`: turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short `ZBErrorResponse`.
+- `src/ZeroBounceWarmup.c` / `include/ZeroBounce/ZeroBounceWarmup.h`: the Warmup API. A separate product on its own host with its own team-scoped key and an `API-Key` header, so it has its own `ZeroBounceWarmup` and never touches the validation key.
+- `src/ZBErrorResponse.c`: turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short `ZBErrorResponse`, with the HTTP status on `status_code`.
- `test/ZeroBounceTest.c`: existing Unity tests.
-- `test/OfflineApiTest.c`: offline suite. `zero_bounce_set_http_perform_for_test` replaces libcurl and records every request.
+- `test/OfflineApiTest.c`, `test/OfflineWarmupTest.c`: offline suites. `zero_bounce_set_http_perform_for_test` replaces libcurl and records every request.
+- `live/live_api.c`: the opt-in live suite. Built only with `-DZEROBOUNCE_BUILD_LIVE_TESTS=ON`, so the default build and CI never compile it.
- `CMakeLists.txt`: the only version field, `project(ZeroBounce VERSION x.y.z)`.
## Commands
@@ -26,13 +28,27 @@ cmake --build build # this is the typecheck; there is no separate lint
CI (`.github/workflows/sdk_ci.yml`) configures, builds, and runs `./build/bin/ZeroBounceTests`. That build is the typecheck. `build/` is generated; do not commit it.
+The live suite is opt-in and is never part of that build:
+
+```bash
+cmake -S . -B build-live -DZEROBOUNCE_BUILD_LIVE_TESTS=ON
+cmake --build build-live --target ZeroBounceLiveApi
+export ZEROBOUNCE_API_KEY=... ZEROBOUNCE_WARMUP_API_KEY=...
+ZEROBOUNCE_LIVE_SPEND=1 ZEROBOUNCE_LIVE_CHANGES=1 ./build-live/bin/ZeroBounceLiveApi
+```
+
+Keys come from the environment only. Never write one into a file, a commit, a log, a fixture or a message.
+
## Conventions
- **One `ZeroBounce` per API key.** `zero_bounce_get_instance()` is a process-wide singleton. Construct a separate object with `new_zero_bounce_instance` per key. The offline concurrency test does this.
- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a function for an endpoint the public docs do not describe. Check before concluding something is undocumented: the S3 and SFTP bulk endpoints live under https://www.zerobounce.net/docs/api-integrations, not the main API reference.
- **Four base URLs on `ZeroBounce`.** `api_base_url`, `bulk_api_base_url` (ends in `/v2`), `bulk_api_root_url` (the S3/SFTP jobs, **no** `/v2`), and `list_evaluator_base_url` / `inbox_placement_base_url`. The last two are separate hosts rather than paths built from the bulk base, so `zero_bounce_initialize_with_base_url` alone leaves them on the live API — use the two setters in tests.
- **`zb_url_escape` is a query encoder.** It turns a space into `+`, which is correct in a query string but a literal plus in a path. Use `p3_escape_path_segment` for anything that goes into a path, such as the list evaluator file id and the Inbox Placement test id.
-- **Two bulk shapes are easy to get wrong.** `validate_sftp_file`'s `file_name` is optional — leaving it `NULL` uses the input bucket from the account's settings, and every other job requires it. `export_sftp_file` sends `export_path`, not the `export_bucket` that `export_s3_file` sends.
+- **The live API wins over the docs.** Where they disagree, follow the API and say so in a comment at the call site, prefixed `LIVE:`. The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer` and lists tests from `GET /tests` (plural); every S3/SFTP job requires both `file_name` and `return_url`, `validate_sftp_file` included, although the docs call its `file_name` optional; `send_evaluated_file` takes a file id as a form field, not an upload; Warmup's `check-domain-dmarc` takes an *email address*, not a domain; and `test-smtp-configuration` answers a failed SMTP login with HTTP 500.
+- **A `"success": false` field on an HTTP 200 is a failure.** `getfile` and `deletefile` report an unknown or deleted file id that way. `zb_success_means_failure` catches it in `zb_read_success_json`, and `ZBErrorResponse.status_code` carries the HTTP status.
+- **`export_sftp_file` sends `export_path`**, not the `export_bucket` that `export_s3_file` sends.
+- **Safety rails.** `zb_api_key_is_clean` rejects a key with surrounding whitespace or control characters, `zb_check_request_url` allows https only, `p3_escape_path_segment` rejects `.` and `..`, `set_write_callback` turns redirects off and restricts the protocol to https, and `zb_redact_api_key` keeps the key out of messages built from a URL. Use them rather than adding a second rail beside them.
- **`p3_append_form_field` does not call `zb_note_http_field`.** The offline tests assert on the recorded request body (`OfflineRequest.body`), not on `has_field`, for anything built with it.
- **Column indexes are 1-based** on bulk send-file name, gender, and IP columns. `0` means the field is left out. `send_file`'s email column is required and is sent as given, including `0`. `ValidateS3FileOptions.email_address_column` uses `-1` for omit, and `0` is sent.
- **Tri-state ints are `-1` omit, `0` false, `1` true.** `timeout`, `activity_data`, `verify_plus`, `has_header_row`, and `allow_phase_2` follow that. Explicit `0` is sent as `false`.
@@ -40,7 +56,8 @@ CI (`.github/workflows/sdk_ci.yml`) configures, builds, and runs `./build/bin/Ze
- **`allow_phase_2` is validation `send_file` only**, and only when `allow_phase_2_is_set` is true. Scoring `send_file` ignores it. `activity_data` on get-file is validation only.
- **`validate_email_batch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. An empty batch IP is JSON `null`.
- **Failures use the error callback**, not a process exit. The message must be short: no raw HTML, no megabyte body, and no `Error: Error:` prefix. An account-level `validate_email_batch` error (`email_address` `all`) is an error callback. A per-address error stays on the success result.
-- **Tests replace HTTP** with `zero_bounce_set_http_perform_for_test`. Read `zero_bounce_test_method`, `zero_bounce_test_url`, `zero_bounce_test_body`, and `zero_bounce_test_field_*` from that callback. They are thread-local. Do not call the live API from the default test run.
+- **Tests replace HTTP** with `zero_bounce_set_http_perform_for_test`. Read `zero_bounce_test_method`, `zero_bounce_test_url`, `zero_bounce_test_body`, `zero_bounce_test_extra_header`, and `zero_bounce_test_field_*` from that callback. They are thread-local. Do not call the live API from anything under `test/`.
+- **Never from the live suite:** creating an Inbox Placement test (it cannot be deleted while running), setting the Inbox Placement webhook (it cannot be cleared), or ZB managed Warmup onboarding (it provisions a mailbox). Everything the `ZEROBOUNCE_LIVE_CHANGES=1` tier creates is removed again in the same run.
- Match the surrounding style. Do not reformat unrelated code.
## Changelog
@@ -59,4 +76,5 @@ Publishing and pushing tags need a maintainer's explicit approval. Do not bump t
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`. That is the only version field. There is no lockfile and no runtime version constant.
3. `cmake --build build` and `./build/bin/ZeroBounceTests`.
+3a. Run the live suite by hand with real keys and both flags set, and paste its summary line into the release notes. The offline suite cannot catch an API that has changed shape underneath the SDK.
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
index 1427666..47f597b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,12 +20,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SFTP/FTP bulk (`validate_sftp_file`, `score_sftp_file`, `email_finder_sftp_file`, `domain_search_sftp_file`, `export_sftp_file`), sharing the new `ZBBulkFileJobOptions`. The server, path and protocol come from your ZeroBounce settings. `validate_sftp_file`'s `file_name` is optional; `export_sftp_file` takes `export_path` rather than the `export_bucket` `export_s3_file` sends.
- `ZBValidateOptions` accepts `domain_info`, `greylist_processing` and `callback_url`. `callback_url` needs `greylist_processing` set to 1 and is rejected without it, and when blank, before a request is sent.
- `ZBValidateResponse` reads `domain_website_exists`, `domain_registrant_company_name` and `message`. `message` is the API's `Message` key, returned instead of a result while a greylist retry is still running.
-- Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `get_inbox_placement_providers`, `get_inbox_placement_test_types`, `create_inbox_placement_test`, `get_inbox_placement_test`, `list_inbox_placement_tests`, `delete_inbox_placement_test`, `get_inbox_placement_webhook`, `set_inbox_placement_webhook`. The API key goes on the query string, bodies are camelCase JSON, and unset fields are left out.
+- Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `get_inbox_placement_providers`, `get_inbox_placement_test_types`, `create_inbox_placement_test`, `get_inbox_placement_test`, `list_inbox_placement_tests`, `delete_inbox_placement_test`, `get_inbox_placement_webhook`, `set_inbox_placement_webhook`. It authenticates with an `Authorization: Bearer` header, bodies are camelCase JSON, and unset fields are left out.
- `zero_bounce_set_list_evaluator_base_url` and `zero_bounce_set_inbox_placement_base_url`.
-- `zb_make_request_with_method_and_body`, for the Tester's PUT and DELETE.
+- `zb_make_request_with_method_and_body` and `zb_make_request_with_extra_header`, for the Tester's PUT and DELETE and its Bearer header.
+- `ZeroBounceWarmup`, a client for the [Warmup API](https://www.zerobounce.net/docs/warmup-api): a separate product with its own host, its own team-scoped key (`zero_bounce_warmup_initialize`, sent in an `API-Key` header) and all 26 documented endpoints. Every call hands the raw JSON body to its callback, or an empty string for the documented empty successes (HTTP 204 and an empty 200); `warmup_download_seeds` and `warmup_download_statistics` hand over CSV text and write it to a path when one is given.
+- `ZBErrorResponse.status_code` carries the HTTP status the request came back with, so a caller can tell 429 from 500 without parsing the message. It is 0 when the error was raised before a response arrived.
+- Safety rails: `zb_api_key_is_clean` rejects a key with surrounding whitespace or control characters (both initializers run it), `zb_check_request_url` refuses anything that is not `https://`, `p3_escape_path_segment` rejects a `.` or `..` id, redirects are never followed and only the https protocol is enabled, and `zb_redact_api_key` strips the key out of any message built from a URL. A regional base URL paired with the single global bulk host warns once.
+- `get_file_streaming` and `scoring_get_file_streaming` write a result file straight to disk as it arrives instead of buffering it in memory first, for a download too large to hold. They are opt-in because a streamed body cannot be inspected as a whole: the first 4 KB are held back and checked, so a JSON or HTML error still reaches the error callback and no partial file is left behind.
+- `live/live_api.c`, an opt-in live suite built only with `-DZEROBOUNCE_BUILD_LIVE_TESTS=ON`, so the default build and CI never compile it. Keys come from `ZEROBOUNCE_API_KEY` and `ZEROBOUNCE_WARMUP_API_KEY`; `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1` opt in to calls that spend credits or make reversible account changes.
### Changed
+- Verified against the live API. Where it disagrees with the docs, the API wins and the call site says so:
+ - `validate_email_batch_with_options` sends `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 `Authorization: Bearer`, not the `api_key` query parameter the rest of the API takes, and its listing is `GET /tests` (plural) — `/test` answers `Method "GET" not allowed`.
+ - Every S3/SFTP job requires both `file_name` and `return_url`, `validate_sftp_file` included, although the docs call its `file_name` optional. Both are checked before the request is sent.
+ - **Breaking:** `send_evaluated_file`'s second argument is the id of a finished evaluation, not a path to upload. The live endpoint takes the file id as a form field.
+- A `"success": false` field on an HTTP 200 now reaches the error callback instead of being returned as a result. `get_file` and `delete_file` report an unknown or deleted file id that way.
- The list evaluator uses its own base URL (`.../v2/listevaluator`) instead of building the path from the bulk base, and the file id is encoded as a single path segment. `send_evaluated_file` stays on the bulk base.
- API requests time out after 120 seconds.
diff --git a/README.md b/README.md
index 1e1c6c6..8728e0a 100644
--- a/README.md
+++ b/README.md
@@ -200,6 +200,18 @@ get_file(zb, file_id, local_download_path, on_success_get_file, on_error);
Bulk validation uses the [v2 bulk API](https://www.zerobounce.net/docs/email-validation-api-quickstart/v2-send-file). Optional [v2 get file](https://www.zerobounce.net/docs/email-validation-api-quickstart/v2-get-file) query parameters are passed with `get_file_with_options` / `scoring_get_file_with_options` and a `GetFileOptions` value from `new_get_file_options()`. Set `download_type` to `ZB_DOWNLOAD_TYPE_PHASE_1`, `ZB_DOWNLOAD_TYPE_PHASE_2`, or `ZB_DOWNLOAD_TYPE_COMBINED`; set `activity_data` to `0` or `1` when you want `activity_data=false` or `true` (validation `get_file_with_options` only; scoring omits this parameter). On `SendFileOptions`, set `allow_phase_2_is_set` to `true` and `allow_phase_2` to the desired value to send `allow_phase_2` for validation `send_file` only (not `scoring_send_file`).
+A result file is downloaded into memory first, which is fine for an ordinary
+list. For one too large to buffer, `get_file_streaming` (and its AI scoring
+counterpart `scoring_get_file_streaming`) writes the body straight to disk as
+it arrives. It is opt-in because a streamed body cannot be inspected as a
+whole, and getfile answers an unknown or deleted file id with a JSON error
+rather than a file: the first 4 KB are held back and checked, so an error still
+reaches the error callback and no partial file is left behind.
+
+```c
+get_file_streaming(zb, file_id, local_download_path, NULL, on_success_get_file, on_error);
+```
+
If the HTTP status is an error, or the body is a JSON error (including some HTTP 200 responses with `"success": false`), the error callback runs with a parsed message—not the get-file success callback. To inspect a raw body string yourself, use `zb_get_file_json_indicates_error(body)`; use `zb_format_get_file_error_message(body)` for a human-readable line (caller must `free()` the returned pointer).
`ZBFileStatusResponse` includes `file_phase_2_status` when the API returns it.
@@ -379,24 +391,26 @@ SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/a
and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs.
These live on the bulkapi host *without* the `/v2` path.
+Every one of these jobs needs both `file_name` and `return_url`. The docs call
+`validate_sftp_file`'s `file_name` optional, but the live API answers
+`Empty parameter: file_name` without it, so the SDK checks both before sending.
+
```c
ZBBulkFileJobOptions options = new_bulk_file_job_options();
/* Amazon S3. file_name is the full path, as returned by list_s3_files. */
options.file_name = "lists/leads.csv";
+options.return_url = "https://example.com/zerobounce-done";
email_finder_s3_file(zb, &options, on_send, on_error);
options.remove_duplicates = 1;
-options.return_url = "https://example.com/zerobounce-done";
domain_search_s3_file(zb, &options, on_send, on_error);
-/* SFTP/FTP. The server, path and protocol come from your ZeroBounce settings.
- file_name may be left NULL for validate_sftp_file alone, which then uses the
- input bucket from those settings. */
+/* SFTP/FTP. The server, path and protocol come from your ZeroBounce settings. */
options = new_bulk_file_job_options();
-validate_sftp_file(zb, &options, on_send, on_error);
-
options.file_name = "my_file.txt";
+options.return_url = "https://example.com/zerobounce-done";
+validate_sftp_file(zb, &options, on_send, on_error);
score_sftp_file(zb, &options, on_send, on_error);
email_finder_sftp_file(zb, &options, on_send, on_error);
domain_search_sftp_file(zb, &options, on_send, on_error);
@@ -408,8 +422,9 @@ export_sftp_file(zb, "", "/exports", on_send, on_error);
## Inbox Placement Tester
-Runs on its own host, takes the API key on the query string, and sends camelCase
-JSON bodies. It spends the account's inbox-test credits.
+Runs on its own host and sends camelCase JSON bodies. It authenticates with an
+`Authorization: Bearer` header rather than the `api_key` parameter the rest of
+the API takes, and it spends the account's inbox-test credits.
```c
get_inbox_placement_providers(zb, on_raw, on_error);
@@ -434,6 +449,67 @@ get_inbox_placement_webhook(zb, on_raw, on_error);
delete_inbox_placement_test(zb, "", on_raw, on_error);
```
+## List evaluator
+
+Upload a list for evaluation, read the result, and hand a finished evaluation
+back for bulk validation. The evaluator has its own base URL, and the file id
+is one path segment, so a slash in it stays encoded.
+
+```c
+send_list_evaluator_file(zb, "emails.csv", 1, on_send, on_error);
+get_list_evaluator_result(zb, "", on_raw, on_error);
+
+/* The id of a finished evaluation, not a path: the live endpoint takes it as
+ a form field. */
+send_evaluated_file(zb, "", on_send, on_error);
+```
+
+## Warmup API
+
+The [Warmup API](https://www.zerobounce.net/docs/warmup-api) is a separate
+product: 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 key.
+
+Every call hands the raw JSON body to its callback, or an empty string for the
+documented empty successes (HTTP 204 and an empty 200). The two download
+endpoints hand over CSV text and write it to a path when one is given.
+
+```c
+#include "ZeroBounce/ZeroBounceWarmup.h"
+
+ZeroBounceWarmup *zbw = new_zero_bounce_warmup_instance();
+zero_bounce_warmup_initialize(zbw, "");
+
+warmup_list_sending_accounts(zbw, on_raw, on_error);
+warmup_get_engagement_rules(zbw, on_raw, on_error);
+warmup_get_progress_today(zbw, on_raw, on_error);
+
+/* Add a from-address you run yourself. A standard rule is 0 to 4. */
+ZBWarmupAddSendingAccountOptions add = new_warmup_add_sending_account_options();
+add.engagement_rule_id = 0;
+add.ignore_conflict = 1;
+warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_SELF_MANAGED, &add,
+ on_raw, on_error);
+
+/* A custom rule: reply_pct 5-45, important_pct 5-40, click_pct 0-70 and at
+ most important_pct x 1.75. All of it is checked before anything is sent. */
+warmup_create_custom_engagement(zbw, "sender@example.com", 10, 20, 30, on_raw, on_error);
+
+/* CSV, and saved to the path as well. */
+warmup_download_seeds(zbw, "seeds.csv", on_raw, on_error);
+
+/* Pass an email address, not a bare domain: that is what the live API wants. */
+warmup_check_domain_dmarc(zbw, "sender@example.com", NULL, on_raw, on_error);
+
+zero_bounce_warmup_free(zbw);
+```
+
+`warmup_test_smtp_configuration` stores nothing and sends its test message to a
+ZeroBounce test recipient, not to `from_address`. The live API answers a failed
+SMTP login with HTTP 500, which arrives as an error with
+`ZBErrorResponse.status_code` 500; the password never appears in that message.
+
## Per-API base URLs
The list evaluator and the Inbox Placement Tester are separate hosts, so
diff --git a/documentation.md b/documentation.md
index 29d2cd5..ff305ff 100644
--- a/documentation.md
+++ b/documentation.md
@@ -189,6 +189,18 @@ get_file(zb, file_id, local_download_path, on_success_get_file, on_error);
Bulk validation uses the [v2 bulk API](https://www.zerobounce.net/docs/email-validation-api-quickstart/v2-send-file). Optional [v2 get file](https://www.zerobounce.net/docs/email-validation-api-quickstart/v2-get-file) query parameters are passed with `get_file_with_options` / `scoring_get_file_with_options` and a `GetFileOptions` value from `new_get_file_options()`. Set `download_type` to `ZB_DOWNLOAD_TYPE_PHASE_1`, `ZB_DOWNLOAD_TYPE_PHASE_2`, or `ZB_DOWNLOAD_TYPE_COMBINED`; set `activity_data` to `0` or `1` when you want `activity_data=false` or `true` (validation `get_file_with_options` only; scoring omits this parameter). On `SendFileOptions`, set `allow_phase_2_is_set` to `true` and `allow_phase_2` to the desired value to send `allow_phase_2` for validation `send_file` only (not `scoring_send_file`).
+A result file is downloaded into memory first, which is fine for an ordinary
+list. For one too large to buffer, `get_file_streaming` (and its AI scoring
+counterpart `scoring_get_file_streaming`) writes the body straight to disk as
+it arrives. It is opt-in because a streamed body cannot be inspected as a
+whole, and getfile answers an unknown or deleted file id with a JSON error
+rather than a file: the first 4 KB are held back and checked, so an error still
+reaches the error callback and no partial file is left behind.
+
+```c
+get_file_streaming(zb, file_id, local_download_path, NULL, on_success_get_file, on_error);
+```
+
If the HTTP status is an error, or the body is a JSON error (including some HTTP 200 responses with `"success": false`), the error callback runs with a parsed message—not the get-file success callback. To inspect a raw body string yourself, use `zb_get_file_json_indicates_error(body)`; use `zb_format_get_file_error_message(body)` for a human-readable line (caller must `free()` the returned pointer).
`ZBFileStatusResponse` includes `file_phase_2_status` when the API returns it.
@@ -367,24 +379,26 @@ SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/a
and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs.
These live on the bulkapi host *without* the `/v2` path.
+Every one of these jobs needs both `file_name` and `return_url`. The docs call
+`validate_sftp_file`'s `file_name` optional, but the live API answers
+`Empty parameter: file_name` without it, so the SDK checks both before sending.
+
```c
ZBBulkFileJobOptions options = new_bulk_file_job_options();
/* Amazon S3. file_name is the full path, as returned by list_s3_files. */
options.file_name = "lists/leads.csv";
+options.return_url = "https://example.com/zerobounce-done";
email_finder_s3_file(zb, &options, on_send, on_error);
options.remove_duplicates = 1;
-options.return_url = "https://example.com/zerobounce-done";
domain_search_s3_file(zb, &options, on_send, on_error);
-/* SFTP/FTP. The server, path and protocol come from your ZeroBounce settings.
- file_name may be left NULL for validate_sftp_file alone, which then uses the
- input bucket from those settings. */
+/* SFTP/FTP. The server, path and protocol come from your ZeroBounce settings. */
options = new_bulk_file_job_options();
-validate_sftp_file(zb, &options, on_send, on_error);
-
options.file_name = "my_file.txt";
+options.return_url = "https://example.com/zerobounce-done";
+validate_sftp_file(zb, &options, on_send, on_error);
score_sftp_file(zb, &options, on_send, on_error);
email_finder_sftp_file(zb, &options, on_send, on_error);
domain_search_sftp_file(zb, &options, on_send, on_error);
@@ -396,8 +410,9 @@ export_sftp_file(zb, "", "/exports", on_send, on_error);
## Inbox Placement Tester
-Runs on its own host, takes the API key on the query string, and sends camelCase
-JSON bodies. It spends the account's inbox-test credits.
+Runs on its own host and sends camelCase JSON bodies. It authenticates with an
+`Authorization: Bearer` header rather than the `api_key` parameter the rest of
+the API takes, and it spends the account's inbox-test credits.
```c
get_inbox_placement_providers(zb, on_raw, on_error);
@@ -422,6 +437,67 @@ get_inbox_placement_webhook(zb, on_raw, on_error);
delete_inbox_placement_test(zb, "", on_raw, on_error);
```
+## List evaluator
+
+Upload a list for evaluation, read the result, and hand a finished evaluation
+back for bulk validation. The evaluator has its own base URL, and the file id
+is one path segment, so a slash in it stays encoded.
+
+```c
+send_list_evaluator_file(zb, "emails.csv", 1, on_send, on_error);
+get_list_evaluator_result(zb, "", on_raw, on_error);
+
+/* The id of a finished evaluation, not a path: the live endpoint takes it as
+ a form field. */
+send_evaluated_file(zb, "", on_send, on_error);
+```
+
+## Warmup API
+
+The [Warmup API](https://www.zerobounce.net/docs/warmup-api) is a separate
+product: 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 key.
+
+Every call hands the raw JSON body to its callback, or an empty string for the
+documented empty successes (HTTP 204 and an empty 200). The two download
+endpoints hand over CSV text and write it to a path when one is given.
+
+```c
+#include "ZeroBounce/ZeroBounceWarmup.h"
+
+ZeroBounceWarmup *zbw = new_zero_bounce_warmup_instance();
+zero_bounce_warmup_initialize(zbw, "");
+
+warmup_list_sending_accounts(zbw, on_raw, on_error);
+warmup_get_engagement_rules(zbw, on_raw, on_error);
+warmup_get_progress_today(zbw, on_raw, on_error);
+
+/* Add a from-address you run yourself. A standard rule is 0 to 4. */
+ZBWarmupAddSendingAccountOptions add = new_warmup_add_sending_account_options();
+add.engagement_rule_id = 0;
+add.ignore_conflict = 1;
+warmup_add_sending_account(zbw, "sender@example.com", ZB_WARMUP_SELF_MANAGED, &add,
+ on_raw, on_error);
+
+/* A custom rule: reply_pct 5-45, important_pct 5-40, click_pct 0-70 and at
+ most important_pct x 1.75. All of it is checked before anything is sent. */
+warmup_create_custom_engagement(zbw, "sender@example.com", 10, 20, 30, on_raw, on_error);
+
+/* CSV, and saved to the path as well. */
+warmup_download_seeds(zbw, "seeds.csv", on_raw, on_error);
+
+/* Pass an email address, not a bare domain: that is what the live API wants. */
+warmup_check_domain_dmarc(zbw, "sender@example.com", NULL, on_raw, on_error);
+
+zero_bounce_warmup_free(zbw);
+```
+
+`warmup_test_smtp_configuration` stores nothing and sends its test message to a
+ZeroBounce test recipient, not to `from_address`. The live API answers a failed
+SMTP login with HTTP 500, which arrives as an error with
+`ZBErrorResponse.status_code` 500; the password never appears in that message.
+
## Per-API base URLs
The list evaluator and the Inbox Placement Tester are separate hosts, so
From 39d3b24d6ce2302f2003429428768ae8cc12a62a Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sat, 24 Oct 2026 11:35:15 +0400
Subject: [PATCH 18/19] fix: do not send uninitialized memory in form-encoded
request bodies
p3_append_form_field starts from a NULL buffer with a zero capacity and grows
it with realloc, which for a NULL pointer is malloc: the block comes back
uninitialized. The code then took strlen of it and strcat'ed onto it without
terminating it first, so whatever bytes the allocator happened to hand over
were sent in front of the first field.
It usually looked fine, because a fresh page is zeroed; once the process had
allocated and freed enough, it was not. The live suite caught it as a
Cloudflare block on add_filter, which also affected send_evaluated_file and
the S3/SFTP file jobs. The offline test pins that each of those bodies starts
at api_key=.
Co-Authored-By: Claude Opus 5
---
CHANGELOG.md | 2 ++
src/Priority3.c | 10 +++++++++-
test/OfflineApiTest.c | 32 ++++++++++++++++++++++++++++++++
3 files changed, 43 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47f597b..5cba271 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Form-encoded request bodies no longer start with stray bytes. The builder grew its buffer with `realloc(NULL, n)`, which hands back uninitialized memory, and then appended to it without terminating it first, so whatever was in that block went out in front of the first field. Depending on what the allocator handed back, `add_filter`, `send_evaluated_file` and the S3/SFTP jobs could be answered with a Cloudflare block instead of a result.
+
- A 403 HTML "Access denied" page is returned as a short error. An HTML page with status 200 is an error as well, including `get_file`, `email_finder_get_file`, and `domain_search_get_file`, instead of a saved file.
- Empty and non-JSON bodies are a short error. A repeated `Error:` / `[Error]:` prefix is removed.
- `validate_email_batch` 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.
diff --git a/src/Priority3.c b/src/Priority3.c
index c77ec1c..ecb8a4f 100644
--- a/src/Priority3.c
+++ b/src/Priority3.c
@@ -59,15 +59,23 @@ static bool p3_append_form_field(char **body, size_t *cap, const char *key, cons
}
if (need > *cap) {
size_t new_cap = (*cap == 0) ? 256 : *cap * 2;
+ bool was_empty = (*body == NULL);
+ char *n;
while (new_cap < need) {
new_cap *= 2;
}
- char *n = realloc(*body, new_cap);
+ n = realloc(*body, new_cap);
if (!n) {
free(ek);
free(ev);
return false;
}
+ /* realloc(NULL, n) is malloc: the block is uninitialized, so the
+ strlen and strcat below would read and append past garbage, and
+ that garbage would go out on the wire. */
+ if (was_empty) {
+ n[0] = '\0';
+ }
*body = n;
*cap = new_cap;
}
diff --git a/test/OfflineApiTest.c b/test/OfflineApiTest.c
index ebcbdda..6973b0e 100644
--- a/test/OfflineApiTest.c
+++ b/test/OfflineApiTest.c
@@ -1182,6 +1182,37 @@ void test_offline_streaming_download(void) {
TEST_ASSERT_NOT_NULL(strstr(g_error_text, "local_download_path"));
}
+void test_offline_form_body_is_not_prefixed_with_garbage(void) {
+ reset_offline();
+ /* The form body starts out as a NULL pointer with a zero capacity, and
+ realloc(NULL, n) hands back an uninitialized block. Appending to it
+ without terminating it first put stray bytes in front of the first
+ field, which the API saw as a malformed request. */
+ ZeroBounce *zb = client_with("form-body-key");
+
+ add_filter(zb, "allow", "domain", "example.com", on_raw, on_error);
+ {
+ OfflineRequest request = only_request();
+ TEST_ASSERT_EQUAL_INT(0, strncmp(request.body, "api_key=", 8));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "&rule=allow"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "&target=domain"));
+ TEST_ASSERT_NOT_NULL(strstr(request.body, "&value=example.com"));
+ }
+
+ reset_offline();
+ send_evaluated_file(zb, "fid-1", on_send, on_error);
+ TEST_ASSERT_EQUAL_INT(0, strncmp(only_request().body, "api_key=", 8));
+
+ reset_offline();
+ {
+ ZBBulkFileJobOptions options = new_bulk_file_job_options();
+ options.file_name = "f.csv";
+ options.return_url = "https://example.com/done";
+ email_finder_s3_file(zb, &options, on_send, on_error);
+ TEST_ASSERT_EQUAL_INT(0, strncmp(only_request().body, "api_key=", 8));
+ }
+}
+
void test_offline_rejects_before_request(void) {
reset_offline();
ZeroBounce *zb = client_with("");
@@ -1377,6 +1408,7 @@ void offline_run_tests(void) {
RUN_TEST(test_offline_safety_rails);
RUN_TEST(test_offline_redirect_is_not_followed);
RUN_TEST(test_offline_streaming_download);
+ RUN_TEST(test_offline_form_body_is_not_prefixed_with_garbage);
RUN_TEST(test_offline_list_evaluator_base_url);
RUN_TEST(test_offline_inbox_placement);
RUN_TEST(test_offline_inbox_placement_detail_error);
From d65a51a2f788fe872a8b932cb18c43d073ce658e Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sat, 24 Oct 2026 17:08:24 +0400
Subject: [PATCH 19/19] fix: get_api_usage read a misspelled key and dropped
two counters
Found by auditing every SDK's api-usage model against the keys the live API
actually returns, after the same class of bug turned up in Rust and iOS.
The API sends sub_status_antispam_system. This SDK asked json-c for
sub_status_anti_spam_system, with an underscore between anti and spam, so the
counter silently stayed 0 on every call. The struct field keeps its name, so
callers are unaffected; only the key it reads is corrected.
sub_status_leading_period_removed and sub_status_alias_address are returned by
the API and were not read at all. Both are now parsed, printed and compared.
Co-Authored-By: Claude Opus 5
---
CHANGELOG.md | 3 +++
include/ZeroBounce/ZBGetApiUsageResponse.h | 2 ++
src/ZBGetApiUsageResponse.c | 14 +++++++++++++-
3 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5cba271..fbaf297 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- `get_api_usage` read `sub_status_anti_spam_system`, but the API sends that counter as `sub_status_antispam_system`, so it was always 0. The struct field keeps its name; only the key it reads is corrected.
+- `get_api_usage` now reads `sub_status_leading_period_removed` and `sub_status_alias_address`, two counters the API returns that were silently discarded.
+
- Form-encoded request bodies no longer start with stray bytes. The builder grew its buffer with `realloc(NULL, n)`, which hands back uninitialized memory, and then appended to it without terminating it first, so whatever was in that block went out in front of the first field. Depending on what the allocator handed back, `add_filter`, `send_evaluated_file` and the S3/SFTP jobs could be answered with a Cloudflare block instead of a result.
- A 403 HTML "Access denied" page is returned as a short error. An HTML page with status 200 is an error as well, including `get_file`, `email_finder_get_file`, and `domain_search_get_file`, instead of a saved file.
diff --git a/include/ZeroBounce/ZBGetApiUsageResponse.h b/include/ZeroBounce/ZBGetApiUsageResponse.h
index 18cde57..b4a250a 100644
--- a/include/ZeroBounce/ZBGetApiUsageResponse.h
+++ b/include/ZeroBounce/ZBGetApiUsageResponse.h
@@ -43,6 +43,8 @@ typedef struct {
int sub_status_accept_all;
int sub_status_gold;
int sub_status_role_based_accept_all;
+ int sub_status_leading_period_removed;
+ int sub_status_alias_address;
char* start_date;
char* end_date;
char* error;
diff --git a/src/ZBGetApiUsageResponse.c b/src/ZBGetApiUsageResponse.c
index 3265879..a6160af 100644
--- a/src/ZBGetApiUsageResponse.c
+++ b/src/ZBGetApiUsageResponse.c
@@ -42,6 +42,8 @@ ZBGetApiUsageResponse new_zb_get_api_usage_response() {
response.sub_status_accept_all = 0;
response.sub_status_gold = 0;
response.sub_status_role_based_accept_all = 0;
+ response.sub_status_leading_period_removed = 0;
+ response.sub_status_alias_address = 0;
response.start_date = "";
response.end_date = "";
response.error = "";
@@ -86,6 +88,8 @@ char* zb_get_api_usage_response_to_string(ZBGetApiUsageResponse* response) {
", sub_status_accept_all=%d"
", sub_status_gold=%d"
", sub_status_role_based_accept_all=%d"
+ ", sub_status_leading_period_removed=%d"
+ ", sub_status_alias_address=%d"
", start_date='%s'"
", end_date='%s'"
", error='%s'"
@@ -128,6 +132,8 @@ char* zb_get_api_usage_response_to_string(ZBGetApiUsageResponse* response) {
response->sub_status_accept_all,
response->sub_status_gold,
response->sub_status_role_based_accept_all,
+ response->sub_status_leading_period_removed,
+ response->sub_status_alias_address,
response->start_date,
response->end_date,
response->error
@@ -179,6 +185,8 @@ char* zb_get_api_usage_response_to_string(ZBGetApiUsageResponse* response) {
response->sub_status_accept_all,
response->sub_status_gold,
response->sub_status_role_based_accept_all,
+ response->sub_status_leading_period_removed,
+ response->sub_status_alias_address,
response->start_date,
response->end_date,
response->error
@@ -205,7 +213,7 @@ ZBGetApiUsageResponse zb_get_api_usage_response_from_json(const json_object* j)
r.sub_status_mail_server_temporary_error = *(int*)get_json_value(j, json_type_int, "sub_status_mail_server_temporary_error", &(int){0});
r.sub_status_mail_server_did_not_respond = *(int*)get_json_value(j, json_type_int, "sub_status_mail_server_did_not_respond", &(int){0});
r.sub_status_grey_listed = *(int*)get_json_value(j, json_type_int, "sub_status_greylisted", &(int){0});
- r.sub_status_anti_spam_system = *(int*)get_json_value(j, json_type_int, "sub_status_anti_spam_system", &(int){0});
+ r.sub_status_anti_spam_system = *(int*)get_json_value(j, json_type_int, "sub_status_antispam_system", &(int){0});
r.sub_status_does_not_accept_mail = *(int*)get_json_value(j, json_type_int, "sub_status_does_not_accept_mail", &(int){0});
r.sub_status_exception_occurred = *(int*)get_json_value(j, json_type_int, "sub_status_exception_occurred", &(int){0});
r.sub_status_failed_syntax_check = *(int*)get_json_value(j, json_type_int, "sub_status_failed_syntax_check", &(int){0});
@@ -224,6 +232,8 @@ ZBGetApiUsageResponse zb_get_api_usage_response_from_json(const json_object* j)
r.sub_status_accept_all = *(int*)get_json_value(j, json_type_int, "sub_status_accept_all", &(int){0});
r.sub_status_gold = *(int*)get_json_value(j, json_type_int, "sub_status_gold", &(int){0});
r.sub_status_role_based_accept_all = *(int*)get_json_value(j, json_type_int, "sub_status_role_based_accept_all", &(int){0});
+ r.sub_status_leading_period_removed = *(int*)get_json_value(j, json_type_int, "sub_status_leading_period_removed", &(int){0});
+ r.sub_status_alias_address = *(int*)get_json_value(j, json_type_int, "sub_status_alias_address", &(int){0});
r.start_date = *(char**)get_json_value(j, json_type_string, "start_date", &(char*){""});
r.end_date = *(char**)get_json_value(j, json_type_string, "end_date", &(char*){""});
r.error = *(char**)get_json_value(j, json_type_string, "error", &(char*){""});
@@ -267,6 +277,8 @@ int compare_zb_get_api_usage_response(const ZBGetApiUsageResponse* response1, co
response1->sub_status_accept_all == response2->sub_status_accept_all &&
response1->sub_status_gold == response2->sub_status_gold &&
response1->sub_status_role_based_accept_all == response2->sub_status_role_based_accept_all &&
+ response1->sub_status_leading_period_removed == response2->sub_status_leading_period_removed &&
+ response1->sub_status_alias_address == response2->sub_status_alias_address &&
strcmp(response1->start_date, response2->start_date) == 0 &&
strcmp(response1->end_date, response2->end_date) == 0 &&
strcmp(response1->error, response2->error) == 0;