From 9c5053520881b7c4f9662a5b1ee9f5c368871ae3 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:52:27 +0400
Subject: [PATCH 1/6] feat: v2 validate parity (verify_plus, activity_data,
batch options)
Add ZBValidateOptions and with_options helpers; map activity fields on ZBValidation.
Co-authored-by: Cursor
---
src/api/validation.rs | 61 +++++++++++++++++++---
src/utility/structures/mod.rs | 1 +
src/utility/structures/validate_options.rs | 6 +++
src/utility/structures/validation.rs | 2 +
4 files changed, 62 insertions(+), 8 deletions(-)
create mode 100644 src/utility/structures/validate_options.rs
diff --git a/src/api/validation.rs b/src/api/validation.rs
index d85a5a0..c9fd52d 100644
--- a/src/api/validation.rs
+++ b/src/api/validation.rs
@@ -7,21 +7,45 @@ use serde_json::{Map as SerdeMap, Value};
use crate::{ZeroBounce, ZBResult};
use crate::utility::{ENDPOINT_VALIDATE, ZBError, ENDPOINT_BATCH_VALIDATE, CONTENT_TYPE_JSON};
use crate::utility::structures::validation::{ZBValidation, ZBBatchValidation};
+use crate::utility::structures::validate_options::ZBValidateOptions;
impl ZeroBounce {
pub fn validate_email_and_ip(&self, email: &str, ip_address: &str) -> ZBResult {
- let mut query_args = HashMap::from([
- ("email", email),
- ]);
+ self.validate_email_with_options(email, ip_address, &ZBValidateOptions::default())
+ }
+
+ pub fn validate_email_with_options(
+ &self,
+ email: &str,
+ ip_address: &str,
+ options: &ZBValidateOptions,
+ ) -> ZBResult {
+ let mut query_args = HashMap::from([("email", email)]);
+ let timeout_param;
+ let activity_param;
+ let verify_param;
if !ip_address.is_empty() {
query_args.insert("ip_address", ip_address);
}
+ if let Some(timeout) = options.timeout {
+ timeout_param = timeout.to_string();
+ query_args.insert("timeout", &timeout_param);
+ }
+ if let Some(activity_data) = options.activity_data {
+ activity_param = if activity_data { "true" } else { "false" };
+ query_args.insert("activity_data", activity_param);
+ }
+ if let Some(verify_plus) = options.verify_plus {
+ verify_param = if verify_plus { "true" } else { "false" };
+ query_args.insert("verify_plus", verify_param);
+ }
let response_content = self.generic_get_request(
- self.url_provider.url_of(ENDPOINT_VALIDATE), query_args
+ self.url_provider.url_of(ENDPOINT_VALIDATE),
+ query_args,
)?;
let validation = from_str::(&response_content)?;
@@ -47,7 +71,11 @@ impl ZeroBounce {
// }
// ```
// After the value is built, serialize and return the resulted string.
- fn batch_validate_prepare_body(&self, emails_and_ip_addresses: Vec<(String, String)>) -> ZBResult {
+ fn batch_validate_prepare_body(
+ &self,
+ emails_and_ip_addresses: Vec<(String, String)>,
+ options: &ZBValidateOptions,
+ ) -> ZBResult {
let email_batch = emails_and_ip_addresses
.into_iter()
.map(|(email, ip_address)|
@@ -60,10 +88,19 @@ impl ZeroBounce {
.map(Value::Object)
.collect::>();
- let request_body_map = SerdeMap::from_iter([
+ let mut request_body_map = SerdeMap::from_iter([
("api_key".to_string(), Value::String(self.api_key.clone())),
("email_batch".to_string(), Value::Array(email_batch)),
]);
+ if let Some(timeout) = options.timeout {
+ request_body_map.insert("timeout".to_string(), Value::Number(timeout.into()));
+ }
+ if let Some(activity_data) = options.activity_data {
+ request_body_map.insert("activity_data".to_string(), Value::Bool(activity_data));
+ }
+ if let Some(verify_plus) = options.verify_plus {
+ request_body_map.insert("verify_plus".to_string(), Value::Bool(verify_plus));
+ }
// let request_body_object = Value::Object(request_body_map);
let mut serializer = serde_json::Serializer::new(Vec::new());
@@ -78,7 +115,15 @@ impl ZeroBounce {
}
pub fn batch_validate(&self, emails_and_ip_addresses: Vec<(String, String)>) -> ZBResult {
- let body_content = self.batch_validate_prepare_body(emails_and_ip_addresses)?;
+ self.batch_validate_with_options(emails_and_ip_addresses, &ZBValidateOptions::default())
+ }
+
+ pub fn batch_validate_with_options(
+ &self,
+ emails_and_ip_addresses: Vec<(String, String)>,
+ options: &ZBValidateOptions,
+ ) -> ZBResult {
+ let body_content = self.batch_validate_prepare_body(emails_and_ip_addresses, options)?;
let url = self.url_provider.url_of(ENDPOINT_BATCH_VALIDATE);
let response = self.client.post(url)
@@ -112,7 +157,7 @@ mod test {
];
let body_result = ZeroBounce::new("some_api_key")
- .batch_validate_prepare_body(emails_and_ip_addresses);
+ .batch_validate_prepare_body(emails_and_ip_addresses, &crate::utility::structures::validate_options::ZBValidateOptions::default());
assert!(body_result.is_ok())
}
diff --git a/src/utility/structures/mod.rs b/src/utility/structures/mod.rs
index 10dd7ef..3267c0f 100644
--- a/src/utility/structures/mod.rs
+++ b/src/utility/structures/mod.rs
@@ -2,6 +2,7 @@ pub mod bulk;
pub mod custom_deserialize;
pub mod generic;
pub mod validate_enums;
+pub mod validate_options;
pub mod validation;
pub use generic::{ActivityData, ApiUsage, FindEmailResponse, FindEmailResponseV2, DomainSearchResponseV2};
diff --git a/src/utility/structures/validate_options.rs b/src/utility/structures/validate_options.rs
new file mode 100644
index 0000000..6118a39
--- /dev/null
+++ b/src/utility/structures/validate_options.rs
@@ -0,0 +1,6 @@
+#[derive(Clone, Debug, Default)]
+pub struct ZBValidateOptions {
+ pub timeout: Option,
+ pub activity_data: Option,
+ pub verify_plus: Option,
+}
diff --git a/src/utility/structures/validation.rs b/src/utility/structures/validation.rs
index 62b76ca..8c541b5 100644
--- a/src/utility/structures/validation.rs
+++ b/src/utility/structures/validation.rs
@@ -15,6 +15,8 @@ pub struct ZBValidation {
pub account: Option,
pub domain: Option,
pub domain_age_days: Option,
+ pub active_in_days: Option,
+ pub active_first_seen: Option,
pub smtp_provider: Option,
pub catchall_domain: Option,
pub mx_record: Option,
From 242c913f42acf47652e3c88afb7af4a1bcfa056a Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:50:10 +0400
Subject: [PATCH 2/6] feat: add score_email GET/POST for /v2/scoring
Co-authored-by: Cursor
---
src/api/mod.rs | 28 ++++++++++++++++++++++++++--
src/lib.rs | 22 ++++++++++++++++++++++
src/utility/mod.rs | 1 +
src/utility/structures/generic.rs | 6 ++++++
4 files changed, 55 insertions(+), 2 deletions(-)
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 2ed98ee..8e14a42 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -9,8 +9,8 @@ use serde_json::from_str;
pub use crate::ZeroBounce;
use crate::utility::structures::generic::{FindEmailResponse, FindEmailResponseV2, DomainSearchResponseV2};
use crate::utility::{ZBError, ZBResult, ENDPOINT_EMAIL_FINDER};
-use crate::utility::structures::{ActivityData, ApiUsage};
-use crate::utility::{ENDPOINT_ACTIVITY_DATA, ENDPOINT_API_USAGE, ENDPOINT_CREDITS};
+use crate::utility::structures::{ActivityData, ApiUsage, ScoreEmailResponse};
+use crate::utility::{ENDPOINT_ACTIVITY_DATA, ENDPOINT_API_USAGE, ENDPOINT_CREDITS, ENDPOINT_SCORING};
/// Builder for the `find_email_v2` API call.
///
@@ -115,6 +115,30 @@ impl ZeroBounce {
Ok(activity_data)
}
+ pub fn score_email(&self, email: &str) -> ZBResult {
+ let query_args = HashMap::from([("email", email)]);
+
+ let response_content = self.generic_get_request(
+ self.url_provider.url_of(ENDPOINT_SCORING),
+ query_args,
+ )?;
+
+ let score = from_str::(&response_content)?;
+ Ok(score)
+ }
+
+ pub fn score_email_post(&self, email: &str) -> ZBResult {
+ let form_args = HashMap::from([("email", email)]);
+
+ let response_content = self.generic_form_post_request(
+ self.url_provider.url_of(ENDPOINT_SCORING),
+ form_args,
+ )?;
+
+ let score = from_str::(&response_content)?;
+ Ok(score)
+ }
+
/// Deprecated: Use `find_email_v2` instead.
///
/// This function is kept for backward compatibility but will be removed in a future version.
diff --git a/src/lib.rs b/src/lib.rs
index 86a493b..3c26e5d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -122,6 +122,28 @@ impl ZeroBounce {
Ok(response_content)
}
+
+ fn generic_form_post_request<'a>(
+ &'a self,
+ url: String,
+ mut form_args: HashMap<&'a str, &'a str>,
+ ) -> ZBResult {
+ form_args.insert("api_key", self.api_key.as_str());
+
+ let response = self.client.post(url).form(&form_args).send()?;
+
+ let response_ok = response.status().is_success();
+ let response_content = response.text()?;
+ if response_content.len() > utility::MAX_JSON_RESPONSE_BYTES {
+ return Err(ZBError::ExplicitError("response body exceeds size limit".into()));
+ }
+
+ if !response_ok {
+ return Err(ZBError::ExplicitError(response_content));
+ }
+
+ Ok(response_content)
+ }
}
fn require_https_url(url: String) -> String {
diff --git a/src/utility/mod.rs b/src/utility/mod.rs
index 9f6f1a3..e86b44a 100644
--- a/src/utility/mod.rs
+++ b/src/utility/mod.rs
@@ -50,6 +50,7 @@ impl From<&ApiBaseUrl> for String {
pub const BULK_URI: &str = "https://bulkapi.zerobounce.net/v2";
pub const ENDPOINT_CREDITS: &str = "/getcredits";
pub const ENDPOINT_ACTIVITY_DATA: &str = "/activity";
+pub const ENDPOINT_SCORING: &str = "/scoring";
pub const ENDPOINT_VALIDATE: &str = "/validate";
pub const ENDPOINT_API_USAGE: &str = "/getapiusage";
pub const ENDPOINT_BATCH_VALIDATE: &str = "/validatebatch";
diff --git a/src/utility/structures/generic.rs b/src/utility/structures/generic.rs
index 3f765a4..0a76b0e 100644
--- a/src/utility/structures/generic.rs
+++ b/src/utility/structures/generic.rs
@@ -57,6 +57,12 @@ pub struct ActivityData {
pub active_in_days: Option,
}
+#[derive(Clone, Debug, Deserialize)]
+pub struct ScoreEmailResponse {
+ pub email: String,
+ pub score: i64,
+}
+
#[derive(Clone, Debug, Deserialize)]
pub struct DomainFormats {
pub format: String,
From 91f857b23d7a06df402b5a191452ed5c818b55a4 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 02:45:18 +0400
Subject: [PATCH 3/6] feat: Priority 3 API parity (bulk finder, filters, S3)
Co-authored-by: Cursor
---
Cargo.toml | 2 +-
src/api/mod.rs | 4 +-
src/api/priority3.rs | 253 +++++++++++++++++++++++++++++++++++++++++++
3 files changed, 257 insertions(+), 2 deletions(-)
create mode 100644 src/api/priority3.rs
diff --git a/Cargo.toml b/Cargo.toml
index eec3479..a585888 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,7 @@ documentation = "https://docs.rs/zero-bounce"
[dependencies]
bytes = "1.4.0"
chrono = "0.4.24"
-reqwest = { version = "0.13.2", features = ["blocking", "multipart", "query"] }
+reqwest = { version = "0.13.2", features = ["blocking", "multipart", "query", "form"] }
serde = { version ="1.0.159", features = ["derive"] }
serde_json = "1.0.95"
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 8e14a42..45751e3 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -1,4 +1,5 @@
pub mod bulk;
+pub mod priority3;
pub mod validation;
use std::collections::HashMap;
@@ -9,7 +10,8 @@ use serde_json::from_str;
pub use crate::ZeroBounce;
use crate::utility::structures::generic::{FindEmailResponse, FindEmailResponseV2, DomainSearchResponseV2};
use crate::utility::{ZBError, ZBResult, ENDPOINT_EMAIL_FINDER};
-use crate::utility::structures::{ActivityData, ApiUsage, ScoreEmailResponse};
+use crate::utility::structures::{ActivityData, ApiUsage};
+use crate::utility::structures::generic::ScoreEmailResponse;
use crate::utility::{ENDPOINT_ACTIVITY_DATA, ENDPOINT_API_USAGE, ENDPOINT_CREDITS, ENDPOINT_SCORING};
/// Builder for the `find_email_v2` API call.
diff --git a/src/api/priority3.rs b/src/api/priority3.rs
new file mode 100644
index 0000000..86d72d2
--- /dev/null
+++ b/src/api/priority3.rs
@@ -0,0 +1,253 @@
+use std::collections::HashMap;
+use std::io::Read;
+
+use reqwest::blocking::multipart::{Form, Part};
+use serde_json::{from_str, Value};
+
+use crate::ZeroBounce;
+use crate::utility::structures::bulk::ZBFileFeedback;
+use crate::utility::{ZBError, ZBResult};
+
+fn bulk_api_root(bulk_url: &str) -> String {
+ let trimmed = bulk_url.trim_end_matches('/');
+ trimmed.strip_suffix("/v2").unwrap_or(trimmed).to_string()
+}
+
+fn post_bulk_root_multipart(
+ client: &ZeroBounce,
+ url: String,
+ file_name: &str,
+ file_bytes: Vec,
+ fields: HashMap,
+) -> ZBResult {
+ let mut form = Form::new()
+ .part(
+ "file",
+ Part::bytes(file_bytes)
+ .file_name(file_name.to_string())
+ .mime_str("text/csv")?,
+ )
+ .text("api_key", client.api_key.clone());
+ for (key, value) in fields {
+ form = form.text(key, value);
+ }
+ let response = client.client.post(url).multipart(form).send()?;
+ let ok = response.status().is_success();
+ let body = response.text()?;
+ if !ok {
+ return Err(ZBError::ExplicitError(body));
+ }
+ Ok(from_str(&body)?)
+}
+
+fn post_bulk_root_form(
+ client: &ZeroBounce,
+ path: &str,
+ fields: HashMap,
+) -> ZBResult {
+ let url = format!("{}/{}", bulk_api_root(&client.url_provider.bulk_url), path.trim_start_matches('/'));
+ let mut form: HashMap<&str, String> = fields
+ .iter()
+ .map(|(k, v)| (k.as_str(), v.clone()))
+ .collect();
+ form.insert("api_key", client.api_key.clone());
+ let response = client.client.post(url).form(&form).send()?;
+ let ok = response.status().is_success();
+ let body = response.text()?;
+ if !ok {
+ return Err(ZBError::ExplicitError(body));
+ }
+ Ok(from_str(&body)?)
+}
+
+fn bulk_root_file_status(client: &ZeroBounce, base: &str, file_id: &str) -> ZBResult {
+ let url = format!("{}/{}/filestatus", bulk_api_root(&client.url_provider.bulk_url), base);
+ client.generic_get_request(url, HashMap::from([("file_id", file_id)]))
+}
+
+impl ZeroBounce {
+ pub fn send_email_finder_file(
+ &self,
+ domain_column: u32,
+ file_name: &str,
+ mut reader: impl Read,
+ ) -> ZBResult {
+ if domain_column == 0 {
+ return Err(ZBError::explicit("empty parameter: domain_column"));
+ }
+ let mut bytes = Vec::new();
+ reader.read_to_end(&mut bytes)?;
+ let url = format!(
+ "{}/email-finder/sendfile",
+ bulk_api_root(&self.url_provider.bulk_url)
+ );
+ post_bulk_root_multipart(
+ self,
+ url,
+ file_name,
+ bytes,
+ HashMap::from([("domain_column".to_string(), domain_column.to_string())]),
+ )
+ }
+
+ pub fn email_finder_file_status(&self, file_id: &str) -> ZBResult {
+ bulk_root_file_status(self, "email-finder", file_id)
+ }
+
+ pub fn domain_search_file_status(&self, file_id: &str) -> ZBResult {
+ bulk_root_file_status(self, "domain-search", file_id)
+ }
+
+ pub fn send_evaluated_file(
+ &self,
+ file_name: &str,
+ mut reader: impl Read,
+ ) -> ZBResult {
+ let mut bytes = Vec::new();
+ reader.read_to_end(&mut bytes)?;
+ let url = format!(
+ "{}/v2/send-evaluated-file/",
+ bulk_api_root(&self.url_provider.bulk_url)
+ );
+ post_bulk_root_multipart(self, url, file_name, bytes, HashMap::new())
+ }
+
+ pub fn send_domain_search_file(
+ &self,
+ domain_column: u32,
+ file_name: &str,
+ mut reader: impl Read,
+ ) -> ZBResult {
+ if domain_column == 0 {
+ return Err(ZBError::explicit("empty parameter: domain_column"));
+ }
+ let mut bytes = Vec::new();
+ reader.read_to_end(&mut bytes)?;
+ let url = format!(
+ "{}/domain-search/sendfile",
+ bulk_api_root(&self.url_provider.bulk_url)
+ );
+ post_bulk_root_multipart(
+ self,
+ url,
+ file_name,
+ bytes,
+ HashMap::from([("domain_column".to_string(), domain_column.to_string())]),
+ )
+ }
+
+ pub fn send_list_evaluator_file(
+ &self,
+ email_address_column: u32,
+ file_name: &str,
+ mut reader: impl Read,
+ ) -> ZBResult {
+ if email_address_column == 0 {
+ return Err(ZBError::explicit("empty parameter: email_address_column"));
+ }
+ let mut bytes = Vec::new();
+ reader.read_to_end(&mut bytes)?;
+ let url = format!(
+ "{}/v2/listevaluator/",
+ bulk_api_root(&self.url_provider.bulk_url)
+ );
+ post_bulk_root_multipart(
+ self,
+ url,
+ file_name,
+ bytes,
+ HashMap::from([(
+ "email_address_column".to_string(),
+ email_address_column.to_string(),
+ )]),
+ )
+ }
+
+ pub fn get_list_evaluator_result(&self, file_id: &str) -> ZBResult {
+ if file_id.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: file_id"));
+ }
+ let url = format!(
+ "{}/v2/listevaluator/{}/",
+ bulk_api_root(&self.url_provider.bulk_url),
+ file_id
+ );
+ let body = self.generic_get_request(url, HashMap::new())?;
+ Ok(from_str(&body)?)
+ }
+
+ pub fn add_filter(&self, rule: &str, target: &str, value: &str) -> ZBResult {
+ let body = self.generic_form_post_request(
+ self.url_provider.url_of("/filters/add"),
+ HashMap::from([
+ ("rule", rule),
+ ("target", target),
+ ("value", value),
+ ]),
+ )?;
+ Ok(from_str(&body)?)
+ }
+
+ pub fn list_filters(&self, use_post: bool) -> ZBResult {
+ let body = if use_post {
+ self.generic_form_post_request(
+ self.url_provider.url_of("/filters/list"),
+ HashMap::new(),
+ )?
+ } else {
+ self.generic_get_request(self.url_provider.url_of("/filters/list"), HashMap::new())?
+ };
+ Ok(from_str(&body)?)
+ }
+
+ pub fn delete_filter(&self, rule: &str, target: &str, value: &str) -> ZBResult {
+ let body = self.generic_form_post_request(
+ self.url_provider.url_of("/filters/delete"),
+ HashMap::from([
+ ("rule", rule),
+ ("target", target),
+ ("value", value),
+ ]),
+ )?;
+ Ok(from_str(&body)?)
+ }
+
+ pub fn validate_s3_file(&self, file_name: &str) -> ZBResult {
+ if file_name.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: file_name"));
+ }
+ post_bulk_root_form(
+ self,
+ "validateS3file",
+ HashMap::from([("file_name".to_string(), file_name.to_string())]),
+ )
+ }
+
+ pub fn score_s3_file(&self, file_name: &str) -> ZBResult {
+ if file_name.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: file_name"));
+ }
+ post_bulk_root_form(
+ self,
+ "scoreS3file",
+ HashMap::from([("file_name".to_string(), file_name.to_string())]),
+ )
+ }
+
+ pub fn export_s3_file(&self, file_id: &str, export_bucket: &str) -> ZBResult {
+ if file_id.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: file_id"));
+ }
+ if export_bucket.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: export_bucket"));
+ }
+ post_bulk_root_form(
+ self,
+ "exportS3file",
+ HashMap::from([
+ ("file_id".to_string(), file_id.to_string()),
+ ("export_bucket".to_string(), export_bucket.to_string()),
+ ]),
+ )
+ }
+}
From 658bd1b7e6256c60f8ead64c7c636863da990ea3 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 03:17:25 +0400
Subject: [PATCH 4/6] 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.
---
src/api/priority3.rs | 32 ++++++++++++++++++++
src/api/validation.rs | 34 +++++++++++++++++++---
src/utility/structures/validate_options.rs | 1 +
3 files changed, 63 insertions(+), 4 deletions(-)
diff --git a/src/api/priority3.rs b/src/api/priority3.rs
index 86d72d2..3264a6c 100644
--- a/src/api/priority3.rs
+++ b/src/api/priority3.rs
@@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::io::Read;
use reqwest::blocking::multipart::{Form, Part};
+use serde::Deserialize;
use serde_json::{from_str, Value};
use crate::ZeroBounce;
@@ -60,6 +61,13 @@ fn post_bulk_root_form(
Ok(from_str(&body)?)
}
+#[derive(Deserialize, Debug)]
+pub struct S3ListFileEntry {
+ pub name: String,
+ pub size: i64,
+ pub last_modified: String,
+}
+
fn bulk_root_file_status(client: &ZeroBounce, base: &str, file_id: &str) -> ZBResult {
let url = format!("{}/{}/filestatus", bulk_api_root(&client.url_provider.bulk_url), base);
client.generic_get_request(url, HashMap::from([("file_id", file_id)]))
@@ -212,6 +220,30 @@ impl ZeroBounce {
Ok(from_str(&body)?)
}
+ pub fn list_s3_files(
+ &self,
+ bucket: Option<&str>,
+ search: Option<&str>,
+ ) -> ZBResult> {
+ let url = format!(
+ "{}/listS3files",
+ bulk_api_root(&self.url_provider.bulk_url)
+ );
+ let mut params: HashMap<&str, &str> = HashMap::new();
+ if let Some(bucket) = bucket {
+ if !bucket.trim().is_empty() {
+ params.insert("bucket", bucket);
+ }
+ }
+ if let Some(search) = search {
+ if !search.trim().is_empty() {
+ params.insert("search", search);
+ }
+ }
+ let body = self.generic_get_request(url, params)?;
+ Ok(from_str(&body)?)
+ }
+
pub fn validate_s3_file(&self, file_name: &str) -> ZBResult {
if file_name.trim().is_empty() {
return Err(ZBError::explicit("empty parameter: file_name"));
diff --git a/src/api/validation.rs b/src/api/validation.rs
index c9fd52d..c08f7fd 100644
--- a/src/api/validation.rs
+++ b/src/api/validation.rs
@@ -43,10 +43,36 @@ impl ZeroBounce {
query_args.insert("verify_plus", verify_param);
}
- let response_content = self.generic_get_request(
- self.url_provider.url_of(ENDPOINT_VALIDATE),
- query_args,
- )?;
+ let response_content = if options.use_post {
+ let mut form_args = HashMap::from([("email", email)]);
+ let timeout_param;
+ let activity_param;
+ let verify_param;
+ if !ip_address.is_empty() {
+ form_args.insert("ip_address", ip_address);
+ }
+ if let Some(timeout) = options.timeout {
+ timeout_param = timeout.to_string();
+ form_args.insert("timeout", &timeout_param);
+ }
+ if let Some(activity_data) = options.activity_data {
+ activity_param = if activity_data { "true" } else { "false" };
+ form_args.insert("activity_data", activity_param);
+ }
+ if let Some(verify_plus) = options.verify_plus {
+ verify_param = if verify_plus { "true" } else { "false" };
+ form_args.insert("verify_plus", verify_param);
+ }
+ self.generic_form_post_request(
+ self.url_provider.url_of(ENDPOINT_VALIDATE),
+ form_args,
+ )?
+ } else {
+ self.generic_get_request(
+ self.url_provider.url_of(ENDPOINT_VALIDATE),
+ query_args,
+ )?
+ };
let validation = from_str::(&response_content)?;
Ok(validation)
diff --git a/src/utility/structures/validate_options.rs b/src/utility/structures/validate_options.rs
index 6118a39..637ffb3 100644
--- a/src/utility/structures/validate_options.rs
+++ b/src/utility/structures/validate_options.rs
@@ -3,4 +3,5 @@ pub struct ZBValidateOptions {
pub timeout: Option,
pub activity_data: Option,
pub verify_plus: Option,
+ pub use_post: bool,
}
From df1639e9d3926077ba485fc9aa4349c7058b4e7b Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sun, 18 Oct 2026 14:59:45 +0400
Subject: [PATCH 5/6] fix: validate request URLs for CodeQL non-https-url
Co-authored-by: Cursor
---
src/api/validation.rs | 2 +-
src/lib.rs | 18 ++++++++++++++++--
tests/common/mod.rs | 2 +-
3 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/src/api/validation.rs b/src/api/validation.rs
index c08f7fd..e46a35f 100644
--- a/src/api/validation.rs
+++ b/src/api/validation.rs
@@ -152,7 +152,7 @@ impl ZeroBounce {
let body_content = self.batch_validate_prepare_body(emails_and_ip_addresses, options)?;
let url = self.url_provider.url_of(ENDPOINT_BATCH_VALIDATE);
- let response = self.client.post(url)
+ let response = self.client.post(crate::parse_request_url(&url)?)
.body(body_content)
.header("content-type", CONTENT_TYPE_JSON)
.send()?;
diff --git a/src/lib.rs b/src/lib.rs
index 3c26e5d..236bcb5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -108,7 +108,7 @@ impl ZeroBounce {
// Automatically add api_key to query arguments
query_args.insert("api_key", self.api_key.as_str());
- let response = self.client.get(url).query(&query_args).send()?;
+ let response = self.client.get(parse_request_url(&url)?).query(&query_args).send()?;
let response_ok = response.status().is_success();
let response_content = response.text()?;
@@ -130,7 +130,7 @@ impl ZeroBounce {
) -> ZBResult {
form_args.insert("api_key", self.api_key.as_str());
- let response = self.client.post(url).form(&form_args).send()?;
+ let response = self.client.post(parse_request_url(&url)?).form(&form_args).send()?;
let response_ok = response.status().is_success();
let response_content = response.text()?;
@@ -146,6 +146,20 @@ impl ZeroBounce {
}
}
+fn parse_request_url(url: &str) -> ZBResult {
+ let parsed = reqwest::Url::parse(url).map_err(|e| ZBError::ExplicitError(e.to_string()))?;
+ if parsed.scheme() == "https" {
+ return Ok(parsed);
+ }
+ if parsed.scheme() == "http" {
+ match parsed.host_str() {
+ Some("127.0.0.1") | Some("localhost") | Some("[::1]") => return Ok(parsed),
+ _ => {}
+ }
+ }
+ Err(ZBError::ExplicitError("request URL must use https://".into()))
+}
+
fn require_https_url(url: String) -> String {
let lower = url.to_ascii_lowercase();
if lower.starts_with("https://") {
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 002b456..86a38b3 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -3,7 +3,7 @@ use zero_bounce::{ZBUrlProvider, ZeroBounce, ZBFile};
pub(crate) const EMAIL: &str = "example@example.com";
pub(crate) const MOCK_API_KEY: &str = "mock_api_key";
-pub(crate) const INVALID_URL: &str = "http://255.255.255.255";
+pub(crate) const INVALID_URL: &str = "https://255.255.255.255";
pub(crate) const SANDBOX_IP: &str = "99.110.204.1";
pub(crate) fn instantiate<'s>() -> (ServerGuard, ZeroBounce) {
From 1b2ff473f99bfbaa39b02be75e62c358e1140e01 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Mon, 19 Oct 2026 06:50:28 +0400
Subject: [PATCH 6/6] fix: turn bad API bodies into short errors
HTML pages, empty payloads, and account-level batch responses were succeeding or exposing parser text, and an empty key was still sent.
Co-authored-by: Cursor
---
.github/workflows/sdk_ci.yml | 3 +
AGENTS.md | 56 ++
CHANGELOG.md | 86 ++
src/api/bulk.rs | 116 +--
src/api/priority3.rs | 51 +-
src/api/validation.rs | 84 +-
src/lib.rs | 105 ++-
src/utility/http_error.rs | 145 +++
src/utility/mod.rs | 29 +-
src/utility/structures/bulk.rs | 91 +-
tests/bulk/ai_scoring.rs | 18 +-
tests/bulk/validation.rs | 18 +-
tests/generic_endpoints/activity_data.rs | 6 +-
tests/generic_endpoints/api_usage.rs | 6 +-
tests/generic_endpoints/domain_search_v2.rs | 6 +-
tests/generic_endpoints/find_email_v2.rs | 6 +-
tests/generic_endpoints/find_mail.rs | 12 +-
tests/generic_endpoints/get_credits.rs | 6 +-
tests/offline_api.rs | 925 ++++++++++++++++++++
tests/validation/batch_validation.rs | 6 +-
tests/validation/simple_validation.rs | 6 +-
21 files changed, 1529 insertions(+), 252 deletions(-)
create mode 100644 AGENTS.md
create mode 100644 CHANGELOG.md
create mode 100644 src/utility/http_error.rs
create mode 100644 tests/offline_api.rs
diff --git a/.github/workflows/sdk_ci.yml b/.github/workflows/sdk_ci.yml
index f8212e6..675544a 100644
--- a/.github/workflows/sdk_ci.yml
+++ b/.github/workflows/sdk_ci.yml
@@ -24,5 +24,8 @@ jobs:
with:
components: rustfmt, clippy
+ - name: Clippy
+ run: cargo clippy --all-targets
+
- name: Run tests
run: cargo test --no-fail-fast
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..0c59555
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,56 @@
+# AGENTS.md
+
+Guidance for AI coding agents working in this repository.
+
+## Project
+
+`zerobounce-rust-api` publishes the `zero-bounce` crate. Callers construct `ZeroBounce::new(api_key)`. Each value owns its key, HTTP client, and base URLs.
+
+## Layout
+
+- `src/lib.rs`: client, URL builder, and the shared GET and form POST helpers.
+- `src/api/validation.rs`: single validate and `batch_validate` on the main API host.
+- `src/api/bulk.rs`: bulk validation and AI scoring file methods on `bulkapi`.
+- `src/api/priority3.rs`: bulk email finder, domain search, list evaluator, filters, and S3. Finder and domain search paths are on the bulk host without `/v2`.
+- `src/utility/http_error.rs`: turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short `ZBError`.
+- `tests/offline_api.rs`: offline suite. A local HTTP server records every request. The client is pointed at `http://127.0.0.1`, which the URL check allows.
+- Version is `version` in `Cargo.toml`. `Cargo.lock` records the same package version. There is no runtime version constant.
+
+## Commands
+
+```bash
+cargo test --no-fail-fast
+cargo clippy --all-targets
+```
+
+`cargo test` compiles the crate and the integration tests. That is the typecheck. CI (`.github/workflows/sdk_ci.yml`) runs `cargo clippy --all-targets` and `cargo test --no-fail-fast`. Clippy is not denied: existing tests use `assert_eq!` on booleans, and `cargo fmt --check` does not pass on the examples. Do not reformat files you did not change.
+
+## Conventions
+
+- **One `ZeroBounce` per API key.** `api_key` is a field on the value. Do not share one value across users. The offline concurrency test builds a separate client per key.
+- **Column indexes.** `email_address_column` is a `u32` and is sent as given, including `0`. Name, gender, and IP columns are `Option`: `None` is omitted, and `Some(0)` is sent. Bulk finder `domain_column` of `0` is rejected.
+- **Optional parameters are omitted when unset.** `timeout`, `activity_data`, `verify_plus`, and `allow_phase_2` are `Option` and are not sent until the caller sets them. Explicit `false` is sent as `false`.
+- **Booleans are the strings `true` and `false`.** `bool::to_string` does that. Do not send `1` or `0`. `allow_phase_2` is validation `sendfile` only. `activity_data` on getfile is validation only. AI scoring `sendfile` does not send name, gender, or IP columns.
+- **Empty `ip_address` is omitted** on single validate. Batch items always include `ip_address`, and an empty string is sent.
+- **`validate` timeout is 3–60 seconds. `batch_validate` timeout is 10–120 seconds.** Out-of-range values are rejected before a request.
+- **Failures are `ZBError`, which implements `std::error::Error`.** The message must be short: no raw HTML, no megabyte body, no serde dump, and no `Error: Error:` prefix. An account-level batch error (`email_address` `all`) is `Err`. A per-address error stays on the `Ok` result.
+- **Tests must not call the live API.** Point `url_provider` at `http://127.0.0.1` or use `mockito`.
+- Match the surrounding style. Do not reformat unrelated code.
+
+## Changelog
+
+`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
+
+- Add every user-visible change to `## [Unreleased]` in the same commit as the change.
+- Group entries under these headings, in this order, omitting empty ones: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`.
+- Write for SDK users: name the method, option, or behaviour, and say what changed. Leave out CI-only changes, internal refactors, and fixes to code that was never released.
+- At 2.y.z, a breaking change bumps the major version, a new method or option bumps the minor version, and fixes alone bump the patch version.
+
+## Releasing
+
+Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked.
+
+1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links at the bottom of `CHANGELOG.md`.
+2. Set the same version in `Cargo.toml` `version` and in the `zero-bounce` package entry in `Cargo.lock`. The publish workflow fails if the tag and `Cargo.toml` disagree.
+3. `cargo test --no-fail-fast`.
+4. Tag `vx.y.z` on that commit and run the Publish workflow with that tag. It publishes to crates.io.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..cbf5b9f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,86 @@
+# 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
+
+- `ZBValidateOptions` for single validation: `timeout`, `activity_data`, `verify_plus`, and `use_post` (form-encoded `POST /v2/validate`). The same options apply to `batch_validate_with_options`.
+- `score_email` (`GET /v2/scoring`) and `score_email_post` (form-encoded `POST /v2/scoring`).
+- Bulk email finder, bulk domain search, list evaluator, filters, and S3 bulk (`validate_s3_file`, `score_s3_file`, `export_s3_file`, `list_s3_files`).
+
+### Changed
+
+- API requests time out after 120 seconds and do not follow redirects.
+- A base URL must be `https://`, except `http://127.0.0.1`, `http://localhost`, and `http://[::1]` for tests.
+
+### Fixed
+
+- An HTML "Access denied" page is returned as a short `ZBError`. An HTML page with status 200 is an error as well, including bulk `getfile`, instead of file bytes or a JSON parse.
+- Empty and non-JSON bodies are a short error. The message is no longer a serde parse dump, and a repeated `Error:` / `[Error]:` prefix is removed.
+- `batch_validate` returns an error for an account-level failure (HTTP 200, error for address `all`, such as an invalid API key or no credits). An error for one address stays on the successful result.
+- AI scoring `sendfile` does not send name, gender, or IP columns, or `allow_phase_2`.
+- An empty API key is rejected before a request is sent.
+- `validate` timeout must be 3–60 seconds. `batch_validate` timeout must be 10–120 seconds.
+
+### Security
+
+- Redirects are disabled, so a 3xx response cannot send the API key to another host.
+- Error values no longer include the raw HTML access-denied page.
+
+## [2.1.2] - 2026-06-10
+
+### Added
+
+- `catchall_domain` on the validation response.
+
+## [2.1.0] - 2026-04-05
+
+### Added
+
+- `ZBGetFileOptions` with `download_type` and `activity_data` (validation getfile only).
+
+### Fixed
+
+- Bulk getfile treats a JSON error body, including HTTP 200, as a failure instead of file content.
+
+## [2.0.11] - 2026-03-03
+
+### Changed
+
+- License the project under MIT.
+
+## [2.0.0] - 2026-03-03
+
+### Changed
+
+- Release the 2.0 line of the client.
+
+## [1.3.0] - 2025-11-10
+
+### Added
+
+- `accept_all` status.
+- Base URL selection (`ApiBaseUrl::Default`, `USA`, and `EU`).
+
+### Deprecated
+
+- `domain_search`. Use `domain_search_v2`.
+
+## [1.1.0] - 2023-09-04
+
+### Added
+
+- Initial client for validation, credits, activity, and bulk file calls.
+
+[Unreleased]: https://github.com/zerobounce/zerobounce-rust-api/compare/v2.1.3...HEAD
+[2.1.2]: https://github.com/zerobounce/zerobounce-rust-api/compare/v2.1.0...v2.1.2
+[2.1.0]: https://github.com/zerobounce/zerobounce-rust-api/compare/v2.0.11...v2.1.0
+[2.0.11]: https://github.com/zerobounce/zerobounce-rust-api/compare/v2.0.0...v2.0.11
+[2.0.0]: https://github.com/zerobounce/zerobounce-rust-api/compare/v1.3.0...v2.0.0
+[1.3.0]: https://github.com/zerobounce/zerobounce-rust-api/compare/v1.1.0...v1.3.0
+[1.1.0]: https://github.com/zerobounce/zerobounce-rust-api/releases/tag/v1.1.0
diff --git a/src/api/bulk.rs b/src/api/bulk.rs
index a711423..4b3877e 100644
--- a/src/api/bulk.rs
+++ b/src/api/bulk.rs
@@ -1,20 +1,26 @@
use std::collections::HashMap;
-use serde_json::from_str;
-
-use crate::ZeroBounce;
-use crate::utility::{ZBResult, ZBError};
-use crate::utility::{ENDPOINT_FILE_SEND, ENDPOINT_FILE_STATUS, ENDPOINT_FILE_RESULT, ENDPOINT_FILE_DELETE};
-use crate::utility::{ENDPOINT_SCORING_DELETE, ENDPOINT_SCORING_STATUS, ENDPOINT_SCORING_RESULT, ENDPOINT_SCORING_SEND};
-use crate::utility::structures::bulk::{ZBBulkResponse, ZBFile, ZBFileFeedback, ZBFileStatus, ZBGetFileOptions};
use crate::utility::bulk_get_file::should_treat_get_file_body_as_error;
use crate::utility::format_get_file_error_message;
-
+use crate::utility::structures::bulk::{
+ ZBBulkResponse, ZBFile, ZBFileFeedback, ZBFileStatus, ZBGetFileOptions,
+};
+use crate::utility::{ZBError, ZBResult};
+use crate::utility::{
+ ENDPOINT_FILE_DELETE, ENDPOINT_FILE_RESULT, ENDPOINT_FILE_SEND, ENDPOINT_FILE_STATUS,
+};
+use crate::utility::{
+ ENDPOINT_SCORING_DELETE, ENDPOINT_SCORING_RESULT, ENDPOINT_SCORING_SEND,
+ ENDPOINT_SCORING_STATUS,
+};
+use crate::ZeroBounce;
impl ZeroBounce {
-
fn generic_file_submit(&self, endpoint: &str, zb_file: &ZBFile) -> ZBResult {
- let mut multi_part_form = zb_file.generate_multipart()?
+ self.require_key()?;
+ let include_person_columns = endpoint == ENDPOINT_FILE_SEND;
+ let mut multi_part_form = zb_file
+ .generate_multipart_with(include_person_columns)?
.text("api_key", self.api_key.clone());
if endpoint == ENDPOINT_FILE_SEND {
@@ -24,32 +30,19 @@ impl ZeroBounce {
}
let url = self.url_provider.bulk_url_of(endpoint);
- let response = self.client.post(url)
- .multipart(multi_part_form)
- .send()?;
-
- let response_ok = response.status().is_success();
- let response_content = response.text()?;
+ let response = self.client.post(url).multipart(multi_part_form).send()?;
- if !response_ok {
- return Err(ZBError::ExplicitError(response_content));
- }
-
- let feedback_object = from_str::(&response_content)?;
- Ok(feedback_object)
+ let response_content = self.read_api_response(response)?;
+ crate::utility::http_error::parse_json_body(&response_content)
}
fn generic_file_status_check(&self, endpoint: &str, file_id: &str) -> ZBResult {
- let query_args = HashMap::from([
- ("file_id", file_id),
- ]);
+ let query_args = HashMap::from([("file_id", file_id)]);
- let response_content = self.generic_get_request(
- self.url_provider.bulk_url_of(endpoint), query_args
- )?;
+ let response_content =
+ self.generic_get_request(self.url_provider.bulk_url_of(endpoint), query_args)?;
- let file_status = from_str::(&response_content)?;
- Ok(file_status)
+ crate::utility::http_error::parse_json_body(&response_content)
}
fn generic_result_fetch(
@@ -59,6 +52,7 @@ impl ZeroBounce {
options: Option<&ZBGetFileOptions>,
is_scoring: bool,
) -> ZBResult {
+ self.require_key()?;
let url = self.url_provider.bulk_url_of(endpoint);
let mut query: Vec<(&str, String)> = vec![
("api_key", self.api_key.clone()),
@@ -70,7 +64,10 @@ impl ZeroBounce {
}
if !is_scoring {
if let Some(ad) = opts.activity_data {
- query.push(("activity_data", if ad { "true".into() } else { "false".into() }));
+ query.push((
+ "activity_data",
+ if ad { "true".into() } else { "false".into() },
+ ));
}
}
}
@@ -88,39 +85,54 @@ impl ZeroBounce {
let body_bytes = response.bytes()?;
if body_bytes.len() > crate::utility::MAX_GETFILE_BYTES {
- return Err(ZBError::ExplicitError("getfile response exceeds size limit".into()));
+ return Err(ZBError::ExplicitError(
+ "getfile response exceeds size limit".into(),
+ ));
}
let body_str = String::from_utf8_lossy(&body_bytes).into_owned();
- if !status.is_success() {
- let msg = if body_str.trim_start().starts_with('{') {
- format_get_file_error_message(body_str.trim())
- } else if body_str.is_empty() {
- format!("HTTP {}", status.as_u16())
- } else {
- body_str
- };
- return Err(ZBError::ExplicitError(msg));
+ if crate::utility::http_error::body_looks_like_html(&body_str)
+ || crate::utility::http_error::content_type_is_html(&content_type)
+ || !status.is_success()
+ {
+ return Err(ZBError::ExplicitError(
+ crate::utility::http_error::message_from_body(
+ status.as_u16(),
+ &content_type,
+ &body_str,
+ ),
+ ));
}
if should_treat_get_file_body_as_error(&body_str, &content_type) {
- return Err(ZBError::ExplicitError(format_get_file_error_message(body_str.trim())));
+ let message = crate::utility::http_error::strip_error_prefix(
+ &format_get_file_error_message(body_str.trim()),
+ );
+ if message.is_empty()
+ || message.len() > 500
+ || crate::utility::http_error::body_looks_like_html(&message)
+ {
+ return Err(ZBError::ExplicitError(
+ crate::utility::http_error::message_from_body(
+ status.as_u16(),
+ &content_type,
+ &body_str,
+ ),
+ ));
+ }
+ return Err(ZBError::ExplicitError(message));
}
Ok(ZBBulkResponse::Content(body_bytes))
}
- fn generic_result_delete(&self, endpoint: &str, file_id: &str) -> ZBResult{
- let query_args = HashMap::from([
- ("file_id", file_id),
- ]);
+ fn generic_result_delete(&self, endpoint: &str, file_id: &str) -> ZBResult {
+ let query_args = HashMap::from([("file_id", file_id)]);
- let response_content = self.generic_get_request(
- self.url_provider.bulk_url_of(endpoint), query_args
- )?;
+ let response_content =
+ self.generic_get_request(self.url_provider.bulk_url_of(endpoint), query_args)?;
- let file_status = from_str::(&response_content)?;
- Ok(file_status)
+ crate::utility::http_error::parse_json_body(&response_content)
}
pub fn bulk_validation_file_submit(&self, zb_file: &ZBFile) -> ZBResult {
@@ -172,6 +184,4 @@ impl ZeroBounce {
pub fn ai_scoring_result_delete(&self, file_id: &str) -> ZBResult {
self.generic_result_delete(ENDPOINT_SCORING_DELETE, file_id)
}
-
-
}
diff --git a/src/api/priority3.rs b/src/api/priority3.rs
index 3264a6c..cf1d762 100644
--- a/src/api/priority3.rs
+++ b/src/api/priority3.rs
@@ -5,9 +5,9 @@ use reqwest::blocking::multipart::{Form, Part};
use serde::Deserialize;
use serde_json::{from_str, Value};
-use crate::ZeroBounce;
use crate::utility::structures::bulk::ZBFileFeedback;
use crate::utility::{ZBError, ZBResult};
+use crate::ZeroBounce;
fn bulk_api_root(bulk_url: &str) -> String {
let trimmed = bulk_url.trim_end_matches('/');
@@ -21,6 +21,7 @@ fn post_bulk_root_multipart(
file_bytes: Vec,
fields: HashMap,
) -> ZBResult {
+ client.require_key()?;
let mut form = Form::new()
.part(
"file",
@@ -33,12 +34,8 @@ fn post_bulk_root_multipart(
form = form.text(key, value);
}
let response = client.client.post(url).multipart(form).send()?;
- let ok = response.status().is_success();
- let body = response.text()?;
- if !ok {
- return Err(ZBError::ExplicitError(body));
- }
- Ok(from_str(&body)?)
+ let body = client.read_api_response(response)?;
+ crate::utility::http_error::parse_json_body(&body)
}
fn post_bulk_root_form(
@@ -46,19 +43,20 @@ fn post_bulk_root_form(
path: &str,
fields: HashMap,
) -> ZBResult {
- let url = format!("{}/{}", bulk_api_root(&client.url_provider.bulk_url), path.trim_start_matches('/'));
+ client.require_key()?;
+ let url = format!(
+ "{}/{}",
+ bulk_api_root(&client.url_provider.bulk_url),
+ path.trim_start_matches('/')
+ );
let mut form: HashMap<&str, String> = fields
.iter()
.map(|(k, v)| (k.as_str(), v.clone()))
.collect();
form.insert("api_key", client.api_key.clone());
let response = client.client.post(url).form(&form).send()?;
- let ok = response.status().is_success();
- let body = response.text()?;
- if !ok {
- return Err(ZBError::ExplicitError(body));
- }
- Ok(from_str(&body)?)
+ let body = client.read_api_response(response)?;
+ crate::utility::http_error::parse_json_body(&body)
}
#[derive(Deserialize, Debug)]
@@ -69,7 +67,11 @@ pub struct S3ListFileEntry {
}
fn bulk_root_file_status(client: &ZeroBounce, base: &str, file_id: &str) -> ZBResult {
- let url = format!("{}/{}/filestatus", bulk_api_root(&client.url_provider.bulk_url), base);
+ let url = format!(
+ "{}/{}/filestatus",
+ bulk_api_root(&client.url_provider.bulk_url),
+ base
+ );
client.generic_get_request(url, HashMap::from([("file_id", file_id)]))
}
@@ -181,17 +183,13 @@ impl ZeroBounce {
file_id
);
let body = self.generic_get_request(url, HashMap::new())?;
- Ok(from_str(&body)?)
+ crate::utility::http_error::parse_json_body(&body)
}
pub fn add_filter(&self, rule: &str, target: &str, value: &str) -> ZBResult {
let body = self.generic_form_post_request(
self.url_provider.url_of("/filters/add"),
- HashMap::from([
- ("rule", rule),
- ("target", target),
- ("value", value),
- ]),
+ HashMap::from([("rule", rule), ("target", target), ("value", value)]),
)?;
Ok(from_str(&body)?)
}
@@ -211,11 +209,7 @@ impl ZeroBounce {
pub fn delete_filter(&self, rule: &str, target: &str, value: &str) -> ZBResult {
let body = self.generic_form_post_request(
self.url_provider.url_of("/filters/delete"),
- HashMap::from([
- ("rule", rule),
- ("target", target),
- ("value", value),
- ]),
+ HashMap::from([("rule", rule), ("target", target), ("value", value)]),
)?;
Ok(from_str(&body)?)
}
@@ -225,10 +219,7 @@ impl ZeroBounce {
bucket: Option<&str>,
search: Option<&str>,
) -> ZBResult> {
- let url = format!(
- "{}/listS3files",
- bulk_api_root(&self.url_provider.bulk_url)
- );
+ let url = format!("{}/listS3files", bulk_api_root(&self.url_provider.bulk_url));
let mut params: HashMap<&str, &str> = HashMap::new();
if let Some(bucket) = bucket {
if !bucket.trim().is_empty() {
diff --git a/src/api/validation.rs b/src/api/validation.rs
index e46a35f..395713a 100644
--- a/src/api/validation.rs
+++ b/src/api/validation.rs
@@ -1,17 +1,14 @@
use std::collections::HashMap;
use serde::Serialize;
-use serde_json::from_str;
use serde_json::{Map as SerdeMap, Value};
-use crate::{ZeroBounce, ZBResult};
-use crate::utility::{ENDPOINT_VALIDATE, ZBError, ENDPOINT_BATCH_VALIDATE, CONTENT_TYPE_JSON};
-use crate::utility::structures::validation::{ZBValidation, ZBBatchValidation};
use crate::utility::structures::validate_options::ZBValidateOptions;
-
+use crate::utility::structures::validation::{ZBBatchValidation, ZBValidation};
+use crate::utility::{ZBError, CONTENT_TYPE_JSON, ENDPOINT_BATCH_VALIDATE, ENDPOINT_VALIDATE};
+use crate::{ZBResult, ZeroBounce};
impl ZeroBounce {
-
pub fn validate_email_and_ip(&self, email: &str, ip_address: &str) -> ZBResult {
self.validate_email_with_options(email, ip_address, &ZBValidateOptions::default())
}
@@ -22,6 +19,13 @@ impl ZeroBounce {
ip_address: &str,
options: &ZBValidateOptions,
) -> ZBResult {
+ if let Some(timeout) = options.timeout {
+ if !(3..=60).contains(&timeout) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: timeout (must be between 3 and 60 seconds)",
+ ));
+ }
+ }
let mut query_args = HashMap::from([("email", email)]);
let timeout_param;
let activity_param;
@@ -63,19 +67,12 @@ impl ZeroBounce {
verify_param = if verify_plus { "true" } else { "false" };
form_args.insert("verify_plus", verify_param);
}
- self.generic_form_post_request(
- self.url_provider.url_of(ENDPOINT_VALIDATE),
- form_args,
- )?
+ self.generic_form_post_request(self.url_provider.url_of(ENDPOINT_VALIDATE), form_args)?
} else {
- self.generic_get_request(
- self.url_provider.url_of(ENDPOINT_VALIDATE),
- query_args,
- )?
+ self.generic_get_request(self.url_provider.url_of(ENDPOINT_VALIDATE), query_args)?
};
- let validation = from_str::(&response_content)?;
- Ok(validation)
+ crate::utility::http_error::parse_json_body(&response_content)
}
pub fn validate_email(&self, email: &str) -> ZBResult {
@@ -104,12 +101,12 @@ impl ZeroBounce {
) -> ZBResult {
let email_batch = emails_and_ip_addresses
.into_iter()
- .map(|(email, ip_address)|
+ .map(|(email, ip_address)| {
[
("email_address".to_string(), Value::String(email)),
("ip_address".to_string(), Value::String(ip_address)),
]
- )
+ })
.map(SerdeMap::::from_iter)
.map(Value::Object)
.collect::>();
@@ -140,7 +137,10 @@ impl ZeroBounce {
Ok(final_string)
}
- pub fn batch_validate(&self, emails_and_ip_addresses: Vec<(String, String)>) -> ZBResult {
+ pub fn batch_validate(
+ &self,
+ emails_and_ip_addresses: Vec<(String, String)>,
+ ) -> ZBResult {
self.batch_validate_with_options(emails_and_ip_addresses, &ZBValidateOptions::default())
}
@@ -149,26 +149,40 @@ impl ZeroBounce {
emails_and_ip_addresses: Vec<(String, String)>,
options: &ZBValidateOptions,
) -> ZBResult {
+ if let Some(timeout) = options.timeout {
+ if !(10..=120).contains(&timeout) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: timeout (must be between 10 and 120 seconds)",
+ ));
+ }
+ }
+ self.require_key()?;
let body_content = self.batch_validate_prepare_body(emails_and_ip_addresses, options)?;
let url = self.url_provider.url_of(ENDPOINT_BATCH_VALIDATE);
- let response = self.client.post(crate::parse_request_url(&url)?)
+ let response = self
+ .client
+ .post(crate::parse_request_url(&url)?)
.body(body_content)
.header("content-type", CONTENT_TYPE_JSON)
.send()?;
- let response_ok = response.status().is_success();
- let response_content = response.text()?;
-
- // Debug: Print raw response to examine structure in debug mode
- if !response_ok {
- return Err(ZBError::ExplicitError(response_content));
+ let response_content = self.read_api_response(response)?;
+ let validation: ZBBatchValidation =
+ crate::utility::http_error::parse_json_body(&response_content)?;
+ for item in &validation.errors {
+ if item.email_address.eq_ignore_ascii_case("all") {
+ let message = crate::utility::http_error::strip_error_prefix(&item.error);
+ let message = if message.is_empty() {
+ "Invalid API Key or your account ran out of credits".to_string()
+ } else {
+ message
+ };
+ return Err(ZBError::ExplicitError(message));
+ }
}
-
- let validation = from_str::(response_content.as_str())?;
Ok(validation)
}
-
}
#[cfg(test)]
@@ -178,14 +192,18 @@ mod test {
#[test]
fn test_serializing_example() {
let emails_and_ip_addresses = vec![
- ("valid@example.com".to_string(), "123.123.123.123".to_string()),
+ (
+ "valid@example.com".to_string(),
+ "123.123.123.123".to_string(),
+ ),
("invalid@example.com".to_string(), "".to_string()),
];
- let body_result = ZeroBounce::new("some_api_key")
- .batch_validate_prepare_body(emails_and_ip_addresses, &crate::utility::structures::validate_options::ZBValidateOptions::default());
+ let body_result = ZeroBounce::new("some_api_key").batch_validate_prepare_body(
+ emails_and_ip_addresses,
+ &crate::utility::structures::validate_options::ZBValidateOptions::default(),
+ );
assert!(body_result.is_ok())
}
-
}
diff --git a/src/lib.rs b/src/lib.rs
index 236bcb5..737eaaf 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,14 +4,14 @@ pub mod utility;
use std::collections::HashMap;
use std::time::Duration;
-pub use crate::utility::{ZBError, ZBResult, ApiBaseUrl};
-pub use crate::utility::structures::{ActivityData, ApiUsage};
+pub use crate::api::{DomainSearchV2Builder, FindEmailV2Builder};
pub use crate::utility::structures::bulk::{
- zb_download_type, ZBFile, ZBFileFeedback, ZBFileStatus, ZBBulkResponse, ZBGetFileOptions,
+ zb_download_type, ZBBulkResponse, ZBFile, ZBFileFeedback, ZBFileStatus, ZBGetFileOptions,
};
-pub use crate::utility::{format_get_file_error_message, get_file_json_indicates_error};
pub use crate::utility::structures::validate_enums::{ZBValidateStatus, ZBValidateSubStatus};
-pub use crate::api::{FindEmailV2Builder, DomainSearchV2Builder};
+pub use crate::utility::structures::{ActivityData, ApiUsage};
+pub use crate::utility::{format_get_file_error_message, get_file_json_indicates_error};
+pub use crate::utility::{ApiBaseUrl, ZBError, ZBResult};
// Structure meant to generate the URLs to be accessed with the HTTP requests
// based on the base API URLs (for the base API and bulk API).
@@ -50,14 +50,14 @@ pub struct ZeroBounce {
// the project.
impl ZeroBounce {
/// Create a new ZeroBounce client instance with the default API URL.
- ///
+ ///
/// # Arguments
/// * `api_key` - Your ZeroBounce API key
- ///
+ ///
/// # Example
/// ```no_run
/// use zero_bounce::ZeroBounce;
- ///
+ ///
/// let zb = ZeroBounce::new("your_api_key");
/// ```
pub fn new(api_key: &str) -> ZeroBounce {
@@ -65,7 +65,7 @@ impl ZeroBounce {
}
/// Create a new ZeroBounce client instance with a custom base URL.
- ///
+ ///
/// # Arguments
/// * `api_key` - Your ZeroBounce API key
/// * `base_url` - Base URL. Can be:
@@ -73,54 +73,79 @@ impl ZeroBounce {
/// - `ApiBaseUrl::USA` - Uses the USA API URL
/// - `ApiBaseUrl::EU` - Uses the EU API URL
/// - `String` or `&str` - Uses a custom URL string
- ///
+ ///
/// # Example
/// ```no_run
/// use zero_bounce::{ZeroBounce, ApiBaseUrl};
- ///
+ ///
/// // Using enum
/// let zb = ZeroBounce::with_base_url("your_api_key", ApiBaseUrl::USA);
- ///
+ ///
/// // Using custom string
/// let zb = ZeroBounce::with_base_url("your_api_key", "https://custom-api.example.com/v2/");
/// ```
- pub fn with_base_url(api_key: &str, base_url: T) -> ZeroBounce
+ pub fn with_base_url(api_key: &str, base_url: T) -> ZeroBounce
where
T: Into,
{
let base_url_string = require_https_url(base_url.into());
-
- let mut url_provider = ZBUrlProvider::default();
- url_provider.url = base_url_string.clone();
-
+
+ let url_provider = ZBUrlProvider {
+ url: base_url_string.clone(),
+ ..ZBUrlProvider::default()
+ };
+
ZeroBounce {
api_key: api_key.to_string(),
base_url: base_url_string,
client: reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(120))
+ .redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::default()),
url_provider,
}
}
- fn generic_get_request<'a>(&'a self, url: String, mut query_args: HashMap<&'a str, &'a str>) -> ZBResult {
- // Automatically add api_key to query arguments
- query_args.insert("api_key", self.api_key.as_str());
-
- let response = self.client.get(parse_request_url(&url)?).query(&query_args).send()?;
+ fn require_key(&self) -> ZBResult<()> {
+ if self.api_key.trim().is_empty() {
+ return Err(ZBError::explicit("api_key is required"));
+ }
+ Ok(())
+ }
- let response_ok = response.status().is_success();
+ fn read_api_response(&self, response: reqwest::blocking::Response) -> ZBResult {
+ let status = response.status().as_u16();
+ let content_type = response
+ .headers()
+ .get(reqwest::header::CONTENT_TYPE)
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or("")
+ .to_string();
let response_content = response.text()?;
if response_content.len() > utility::MAX_JSON_RESPONSE_BYTES {
- return Err(ZBError::ExplicitError("response body exceeds size limit".into()));
+ return Err(ZBError::ExplicitError(
+ "response body exceeds size limit".into(),
+ ));
}
+ utility::http_error::ensure_json_api_body(status, &content_type, &response_content)?;
+ Ok(response_content)
+ }
- if !response_ok {
- return Err(ZBError::ExplicitError(response_content));
- }
+ fn generic_get_request<'a>(
+ &'a self,
+ url: String,
+ mut query_args: HashMap<&'a str, &'a str>,
+ ) -> ZBResult {
+ self.require_key()?;
+ query_args.insert("api_key", self.api_key.as_str());
- Ok(response_content)
+ let response = self
+ .client
+ .get(parse_request_url(&url)?)
+ .query(&query_args)
+ .send()?;
+ self.read_api_response(response)
}
fn generic_form_post_request<'a>(
@@ -128,21 +153,15 @@ impl ZeroBounce {
url: String,
mut form_args: HashMap<&'a str, &'a str>,
) -> ZBResult {
+ self.require_key()?;
form_args.insert("api_key", self.api_key.as_str());
- let response = self.client.post(parse_request_url(&url)?).form(&form_args).send()?;
-
- let response_ok = response.status().is_success();
- let response_content = response.text()?;
- if response_content.len() > utility::MAX_JSON_RESPONSE_BYTES {
- return Err(ZBError::ExplicitError("response body exceeds size limit".into()));
- }
-
- if !response_ok {
- return Err(ZBError::ExplicitError(response_content));
- }
-
- Ok(response_content)
+ let response = self
+ .client
+ .post(parse_request_url(&url)?)
+ .form(&form_args)
+ .send()?;
+ self.read_api_response(response)
}
}
@@ -157,7 +176,9 @@ fn parse_request_url(url: &str) -> ZBResult {
_ => {}
}
}
- Err(ZBError::ExplicitError("request URL must use https://".into()))
+ Err(ZBError::ExplicitError(
+ "request URL must use https://".into(),
+ ))
}
fn require_https_url(url: String) -> String {
diff --git a/src/utility/http_error.rs b/src/utility/http_error.rs
new file mode 100644
index 0000000..7ad50f3
--- /dev/null
+++ b/src/utility/http_error.rs
@@ -0,0 +1,145 @@
+use super::{ZBError, ZBResult};
+
+pub fn body_looks_like_html(body: &str) -> bool {
+ let sample = if body.len() > 2000 {
+ &body[..2000]
+ } else {
+ body
+ };
+ let lower = sample.to_ascii_lowercase();
+ lower.contains(" bool {
+ content_type.to_ascii_lowercase().contains("text/html")
+}
+
+pub fn strip_error_prefix(message: &str) -> String {
+ let mut message = message.trim().to_string();
+ loop {
+ let trimmed = message.trim();
+ if let Some(rest) = trimmed.strip_prefix("Error:") {
+ message = rest.trim().to_string();
+ continue;
+ }
+ if let Some(rest) = trimmed.strip_prefix("[Error]:") {
+ message = rest.trim().to_string();
+ continue;
+ }
+ return trimmed.to_string();
+ }
+}
+
+fn html_title(body: &str) -> String {
+ let lower = body.to_ascii_lowercase();
+ let Some(start) = lower.find("') else {
+ return String::new();
+ };
+ let open_end = start + open_end_rel;
+ let Some(close_rel) = lower[open_end..].find("") else {
+ return String::new();
+ };
+ body[open_end + 1..open_end + close_rel].trim().to_string()
+}
+
+fn first_api_error_field(object: &serde_json::Map) -> String {
+ for key in ["error", "message", "error_message", "Message"] {
+ match object.get(key) {
+ Some(serde_json::Value::String(text)) if !text.trim().is_empty() => {
+ return text.trim().to_string();
+ }
+ Some(serde_json::Value::Array(items)) => {
+ if let Some(serde_json::Value::String(text)) = items.first() {
+ if !text.trim().is_empty() {
+ return text.trim().to_string();
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ String::new()
+}
+
+pub fn message_from_body(status: u16, content_type: &str, body: &str) -> String {
+ if body_looks_like_html(body) || content_type_is_html(content_type) {
+ let title = html_title(body);
+ if title.is_empty() {
+ return "ZeroBounce returned an HTML error page instead of an API response.".into();
+ }
+ return format!(
+ "ZeroBounce returned an HTML error page (\"{title}\") instead of an API response."
+ );
+ }
+ let trimmed = body.trim();
+ if trimmed.is_empty() {
+ return format!("Empty response from ZeroBounce (HTTP {status})");
+ }
+ if trimmed.starts_with('{') || trimmed.starts_with('[') {
+ match serde_json::from_str::(trimmed) {
+ Ok(serde_json::Value::Object(object)) => {
+ if let Some(serde_json::Value::String(title)) = object.get("title") {
+ let access = title.contains("Access denied") || title.contains("1020");
+ let cloudflare =
+ object.get("cloudflare_error") == Some(&serde_json::Value::Bool(true));
+ if access && cloudflare {
+ return title.clone();
+ }
+ }
+ let message = first_api_error_field(&object);
+ if !message.is_empty() {
+ return strip_error_prefix(&message);
+ }
+ if trimmed.len() > 1000 {
+ return "ZeroBounce returned a response that is not valid JSON.".into();
+ }
+ return strip_error_prefix(trimmed);
+ }
+ Ok(_) => {
+ if trimmed.len() > 1000 {
+ return "ZeroBounce returned a response that is not valid JSON.".into();
+ }
+ return strip_error_prefix(trimmed);
+ }
+ Err(_) => return "ZeroBounce returned a response that is not valid JSON.".into(),
+ }
+ }
+ if trimmed.len() > 1000 {
+ return "ZeroBounce returned a response that is not valid JSON.".into();
+ }
+ let message = strip_error_prefix(trimmed);
+ if message.is_empty() {
+ return format!("Empty response from ZeroBounce (HTTP {status})");
+ }
+ message
+}
+
+/// Accept a 2xx JSON document. Anything else is a short error.
+pub fn ensure_json_api_body(status: u16, content_type: &str, body: &str) -> ZBResult<()> {
+ let success = (200..300).contains(&status);
+ let trimmed = body.trim();
+ if !success
+ || trimmed.is_empty()
+ || body_looks_like_html(body)
+ || content_type_is_html(content_type)
+ || (!trimmed.starts_with('{') && !trimmed.starts_with('['))
+ {
+ return Err(ZBError::ExplicitError(message_from_body(
+ status,
+ content_type,
+ body,
+ )));
+ }
+ Ok(())
+}
+
+pub fn parse_json_body(body: &str) -> ZBResult {
+ serde_json::from_str(body)
+ .map_err(|_| ZBError::explicit("ZeroBounce returned a response that is not valid JSON."))
+}
diff --git a/src/utility/mod.rs b/src/utility/mod.rs
index e86b44a..72ec451 100644
--- a/src/utility/mod.rs
+++ b/src/utility/mod.rs
@@ -1,8 +1,9 @@
use std::io::Error as IOError;
-pub mod structures;
-pub mod mock_constants;
pub mod bulk_get_file;
+pub mod http_error;
+pub mod mock_constants;
+pub mod structures;
pub use bulk_get_file::{format_get_file_error_message, get_file_json_indicates_error};
@@ -116,6 +117,28 @@ impl ZBError {
}
}
+impl std::fmt::Display for ZBError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ZBError::ExplicitError(message) => write!(formatter, "{message}"),
+ ZBError::JsonError(_) => {
+ write!(
+ formatter,
+ "ZeroBounce returned a response that is not valid JSON."
+ )
+ }
+ ZBError::IntParseError(_) => write!(
+ formatter,
+ "ZeroBounce returned a response that could not be read."
+ ),
+ ZBError::RequestError(error) => write!(formatter, "{error}"),
+ ZBError::IOError(error) => write!(formatter, "{error}"),
+ }
+ }
+}
+
+impl std::error::Error for ZBError {}
+
// Implementation made in order to automatically convert errors
// generated by the reqwuest library into a ZBError instance.
//
@@ -162,4 +185,4 @@ impl From for ZBError {
fn from(value: IOError) -> Self {
ZBError::IOError(value)
}
-}
\ No newline at end of file
+}
diff --git a/src/utility/structures/bulk.rs b/src/utility/structures/bulk.rs
index 27c2c38..2e3a2ae 100644
--- a/src/utility/structures/bulk.rs
+++ b/src/utility/structures/bulk.rs
@@ -7,10 +7,9 @@ use reqwest::blocking::multipart::{Form, Part};
use serde::Deserialize;
-use crate::utility::{ZBResult, ZBError};
use crate::utility::structures::custom_deserialize::deserialize_date_rfc;
use crate::utility::structures::custom_deserialize::deserialize_percentage_float;
-
+use crate::utility::{ZBError, ZBResult};
#[derive(Clone, Debug, Deserialize)]
pub struct ZBFileFeedback {
@@ -20,7 +19,6 @@ pub struct ZBFileFeedback {
pub file_id: Option,
}
-
#[derive(Clone, Debug, Deserialize)]
pub struct ZBFileStatus {
pub success: bool,
@@ -32,10 +30,10 @@ pub struct ZBFileStatus {
pub file_phase_2_status: Option,
pub return_url: Option,
- #[serde(deserialize_with="deserialize_date_rfc")]
+ #[serde(deserialize_with = "deserialize_date_rfc")]
pub upload_date: DateTime,
- #[serde(deserialize_with="deserialize_percentage_float")]
+ #[serde(deserialize_with = "deserialize_percentage_float")]
pub complete_percentage: f32,
}
@@ -64,10 +62,10 @@ impl Debug for ZBBulkResponse {
match &self {
Self::Content(cnt) => {
write!(f, "", cnt.len())
- },
+ }
Self::Feedback(feedback) => {
write!(f, "", feedback)
- },
+ }
}
}
}
@@ -125,7 +123,6 @@ impl Default for ZBFile {
}
impl ZBFile {
-
pub fn from_path(path_to_file: String) -> ZBFile {
ZBFile {
content_type: ZBFileContentType::FilePath(path_to_file),
@@ -159,39 +156,46 @@ impl ZBFile {
fn file_content_multipart(&self) -> ZBResult {
match self.content_type.clone() {
ZBFileContentType::Empty => Err(ZBError::explicit("bulk content cannot be empty")),
- ZBFileContentType::FilePath(file_path) => Ok(
- Part::file(file_path.clone())?
- ),
+ ZBFileContentType::FilePath(file_path) => Ok(Part::file(file_path.clone())?),
ZBFileContentType::RawContent(value) => {
- let name = self.file_name.clone().unwrap_or_else(|| "file.csv".to_string());
- Ok(
- Part::bytes(value)
- .file_name(name)
- .mime_str("text/csv")?
- )
+ let name = self
+ .file_name
+ .clone()
+ .unwrap_or_else(|| "file.csv".to_string());
+ Ok(Part::bytes(value).file_name(name).mime_str("text/csv")?)
}
}
}
pub fn generate_multipart(&self) -> ZBResult
",
+ "Error: Error:",
+ "expected",
+ "serde",
+ ] {
+ assert!(!message.contains(forbidden), "{message}");
+ }
+}
+
+#[test]
+fn version_constant_matches_package() {
+ assert_eq!(
+ ApiBaseUrl::Default.as_str(),
+ "https://api.zerobounce.net/v2/"
+ );
+ assert_eq!(BULK_URI, "https://bulkapi.zerobounce.net/v2");
+ assert_eq!(env!("CARGO_PKG_VERSION"), "2.1.3");
+}
+
+#[test]
+fn credits_validate_and_batch_use_the_main_api() {
+ let (port, fake) = start_fake();
+ let zb = client(port, "credits-key");
+ assert_eq!(zb.get_credits().unwrap(), 7);
+ let request = only(&fake);
+ assert_eq!(request.method, "GET");
+ assert!(
+ request.path.ends_with("/api/v2/getcredits"),
+ "{}",
+ request.path
+ );
+ assert_eq!(request.api_key, "credits-key");
+
+ let mut options = ZBValidateOptions::default();
+ options.activity_data = Some(false);
+ options.verify_plus = Some(true);
+ let response = zb
+ .validate_email_with_options("person@example.com", "", &options)
+ .unwrap();
+ assert_eq!(response.address, "credits-key");
+ let request = only(&fake);
+ assert!(request.path.ends_with("/validate"), "{}", request.path);
+ assert_eq!(
+ request.fields.get("email").map(String::as_str),
+ Some("person@example.com")
+ );
+ assert_eq!(
+ request.fields.get("activity_data").map(String::as_str),
+ Some("false")
+ );
+ assert_eq!(
+ request.fields.get("verify_plus").map(String::as_str),
+ Some("true")
+ );
+ assert!(!request.fields.contains_key("timeout"));
+ assert!(!request.fields.contains_key("ip_address"));
+
+ let mut posted = ZBValidateOptions::default();
+ posted.use_post = true;
+ posted.timeout = Some(15);
+ zb.validate_email_with_options("person@example.com", "203.0.113.5", &posted)
+ .unwrap();
+ let request = only(&fake);
+ assert_eq!(request.method, "POST");
+ assert_eq!(
+ request.fields.get("timeout").map(String::as_str),
+ Some("15")
+ );
+ assert_eq!(
+ request.fields.get("ip_address").map(String::as_str),
+ Some("203.0.113.5")
+ );
+ assert!(!request.fields.contains_key("activity_data"));
+
+ let batch = zb
+ .batch_validate(vec![
+ ("a@example.com".into(), String::new()),
+ ("b@example.com".into(), "203.0.113.9".into()),
+ ])
+ .unwrap();
+ assert_eq!(batch.email_batch[0].address, "credits-key");
+ let request = only(&fake);
+ assert_eq!(request.method, "POST");
+ assert!(
+ request.path.contains("/api/v2/validatebatch"),
+ "{}",
+ request.path
+ );
+ assert!(request
+ .fields
+ .get("email_batch")
+ .unwrap()
+ .contains("a@example.com"));
+ assert!(!request.fields.contains_key("timeout"));
+
+ let mut batch_options = ZBValidateOptions::default();
+ batch_options.timeout = Some(30);
+ batch_options.activity_data = Some(false);
+ batch_options.verify_plus = Some(true);
+ zb.batch_validate_with_options(
+ vec![("a@example.com".into(), String::new())],
+ &batch_options,
+ )
+ .unwrap();
+ let request = only(&fake);
+ assert_eq!(
+ request.fields.get("timeout").map(String::as_str),
+ Some("30")
+ );
+ assert_eq!(
+ request.fields.get("activity_data").map(String::as_str),
+ Some("false")
+ );
+ assert_eq!(
+ request.fields.get("verify_plus").map(String::as_str),
+ Some("true")
+ );
+}
+
+#[test]
+fn files_scoring_and_priority3() {
+ let (port, fake) = start_fake();
+ let zb = client(port, "file-key");
+ let file = ZBFile::from_content(b"email\n".to_vec())
+ .set_email_address_column(0)
+ .set_remove_duplicate(false)
+ .set_has_header_row(true)
+ .set_allow_phase_2(Some(false))
+ .set_return_url(Some("https://example.com/hook"));
+ let sent = zb.bulk_validation_file_submit(&file).unwrap();
+ assert_eq!(sent.file_id.as_deref(), Some("file-key"));
+ let request = only(&fake);
+ assert!(
+ request.path.ends_with("/bulk/v2/sendfile"),
+ "{}",
+ request.path
+ );
+ assert_eq!(
+ request
+ .fields
+ .get("email_address_column")
+ .map(String::as_str),
+ Some("0")
+ );
+ assert_eq!(
+ request.fields.get("has_header_row").map(String::as_str),
+ Some("true")
+ );
+ assert_eq!(
+ request.fields.get("remove_duplicate").map(String::as_str),
+ Some("false")
+ );
+ assert_eq!(
+ request.fields.get("allow_phase_2").map(String::as_str),
+ Some("false")
+ );
+ assert_eq!(
+ request.fields.get("file").map(String::as_str),
+ Some("")
+ );
+ assert!(!request.fields.contains_key("first_name_column"));
+
+ let scoring = file
+ .set_first_name_column(Some(2))
+ .set_allow_phase_2(Some(true));
+ zb.ai_scoring_file_submit(&scoring).unwrap();
+ let request = only(&fake);
+ assert!(
+ request.path.ends_with("/scoring/sendfile"),
+ "{}",
+ request.path
+ );
+ assert!(!request.fields.contains_key("allow_phase_2"));
+ assert!(!request.fields.contains_key("first_name_column"));
+
+ assert_eq!(
+ zb.bulk_validation_file_status_check("file-1")
+ .unwrap()
+ .file_id,
+ "file-key"
+ );
+ assert!(only(&fake).path.ends_with("/filestatus"));
+ zb.ai_scoring_file_status_check("file-2").unwrap();
+ assert!(only(&fake).path.contains("/scoring/filestatus"));
+ zb.bulk_validation_result_delete("file-1").unwrap();
+ assert!(only(&fake).path.ends_with("/deletefile"));
+ zb.ai_scoring_result_delete("file-2").unwrap();
+ assert!(only(&fake).path.contains("/scoring/deletefile"));
+
+ let options = ZBGetFileOptions {
+ download_type: Some(zb_download_type::PHASE_2.into()),
+ activity_data: Some(true),
+ };
+ match zb
+ .bulk_validation_result_fetch_with_options("file-9", &options)
+ .unwrap()
+ {
+ ZBBulkResponse::Content(bytes) => {
+ assert!(String::from_utf8_lossy(&bytes).contains("file-key@example.com"))
+ }
+ other => panic!("{other:?}"),
+ }
+ let request = only(&fake);
+ assert!(request.path.ends_with("/getfile"));
+ assert_eq!(
+ request.fields.get("download_type").map(String::as_str),
+ Some("phase_2")
+ );
+ assert_eq!(
+ request.fields.get("activity_data").map(String::as_str),
+ Some("true")
+ );
+ let scoring_options = ZBGetFileOptions {
+ download_type: Some(zb_download_type::PHASE_1.into()),
+ activity_data: Some(true),
+ };
+ zb.ai_scoring_result_fetch_with_options("file-8", &scoring_options)
+ .unwrap();
+ let request = only(&fake);
+ assert!(request.path.contains("/scoring/getfile"));
+ assert!(!request.fields.contains_key("activity_data"));
+
+ let start = NaiveDate::from_ymd_opt(2018, 1, 1).unwrap();
+ let end = NaiveDate::from_ymd_opt(2018, 1, 31).unwrap();
+ assert_eq!(zb.get_api_usage(start, end).unwrap().total, 1);
+ let request = only(&fake);
+ assert!(request.path.ends_with("/getapiusage"));
+ assert_eq!(
+ request.fields.get("start_date").map(String::as_str),
+ Some("2018-01-01")
+ );
+ assert!(zb.get_activity_data("person@example.com").unwrap().found);
+ assert!(only(&fake).path.ends_with("/activity"));
+ assert_eq!(
+ zb.score_email("person@example.com").unwrap().email,
+ "file-key"
+ );
+ assert!(only(&fake).path.ends_with("/scoring"));
+ zb.score_email_post("person@example.com").unwrap();
+ let request = only(&fake);
+ assert_eq!(request.method, "POST");
+ assert!(request.path.ends_with("/scoring"));
+
+ let found = zb
+ .find_email_v2()
+ .domain("example.com")
+ .first_name("Ada")
+ .middle_name("M")
+ .last_name("Lovelace")
+ .call()
+ .unwrap();
+ assert_eq!(found.email, "file-key");
+ let request = only(&fake);
+ assert!(request.path.ends_with("/guessformat"), "{}", request.path);
+ assert_eq!(
+ request.fields.get("domain").map(String::as_str),
+ Some("example.com")
+ );
+ assert!(!request.fields.contains_key("company_name"));
+ zb.find_email_v2()
+ .company_name("ZeroBounce")
+ .first_name("Ada")
+ .call()
+ .unwrap();
+ let request = only(&fake);
+ assert_eq!(
+ request.fields.get("company_name").map(String::as_str),
+ Some("ZeroBounce")
+ );
+ assert!(!request.fields.contains_key("domain"));
+ assert!(!request.fields.contains_key("last_name"));
+ assert_eq!(
+ zb.domain_search_v2()
+ .domain("example.com")
+ .call()
+ .unwrap()
+ .domain,
+ "file-key"
+ );
+ assert!(!only(&fake).fields.contains_key("company_name"));
+ zb.domain_search_v2()
+ .company_name("ZeroBounce")
+ .call()
+ .unwrap();
+ let request = only(&fake);
+ assert_eq!(
+ request.fields.get("company_name").map(String::as_str),
+ Some("ZeroBounce")
+ );
+ assert!(!request.fields.contains_key("domain"));
+
+ zb.send_email_finder_file(1, "finder.csv", &b"example.com\n"[..])
+ .unwrap();
+ let request = only(&fake);
+ assert!(
+ request.path.ends_with("/email-finder/sendfile"),
+ "{}",
+ request.path
+ );
+ assert_eq!(
+ request.fields.get("domain_column").map(String::as_str),
+ Some("1")
+ );
+ zb.email_finder_file_status("fid").unwrap();
+ assert!(only(&fake).path.ends_with("/email-finder/filestatus"));
+ zb.send_domain_search_file(2, "domains.csv", &b"example.com\n"[..])
+ .unwrap();
+ let request = only(&fake);
+ assert!(request.path.ends_with("/domain-search/sendfile"));
+ assert_eq!(
+ request.fields.get("domain_column").map(String::as_str),
+ Some("2")
+ );
+ zb.domain_search_file_status("fid").unwrap();
+ assert!(only(&fake).path.ends_with("/domain-search/filestatus"));
+ zb.send_list_evaluator_file(1, "list.csv", &b"a@example.com\n"[..])
+ .unwrap();
+ assert!(only(&fake).path.contains("/listevaluator/"));
+ zb.get_list_evaluator_result("fid").unwrap();
+ assert!(only(&fake).path.contains("/listevaluator/fid/"));
+ zb.send_evaluated_file("evaluated.csv", &b"a@example.com\n"[..])
+ .unwrap();
+ assert!(only(&fake).path.contains("/send-evaluated-file/"));
+
+ zb.add_filter("exclude", "domain", "example.com").unwrap();
+ let request = only(&fake);
+ assert_eq!(request.method, "POST");
+ assert!(
+ request.path.contains("/api/v2/filters/add"),
+ "{}",
+ request.path
+ );
+ assert_eq!(
+ request.fields.get("rule").map(String::as_str),
+ Some("exclude")
+ );
+ zb.list_filters(false).unwrap();
+ assert_eq!(only(&fake).method, "GET");
+ zb.list_filters(true).unwrap();
+ assert_eq!(only(&fake).method, "POST");
+ zb.delete_filter("exclude", "domain", "example.com")
+ .unwrap();
+ assert!(only(&fake).path.ends_with("/filters/delete"));
+ zb.validate_s3_file("inbox.csv").unwrap();
+ assert!(only(&fake).path.ends_with("/validateS3file"));
+ zb.score_s3_file("inbox.csv").unwrap();
+ let request = only(&fake);
+ assert!(request.path.ends_with("/scoreS3file"));
+ assert!(!request.fields.contains_key("email_address_column"));
+ zb.export_s3_file("fid", "bucket-out").unwrap();
+ let request = only(&fake);
+ assert_eq!(
+ request.fields.get("export_bucket").map(String::as_str),
+ Some("bucket-out")
+ );
+ let files = zb.list_s3_files(None, None).unwrap();
+ assert_eq!(files[0].name, "file-key");
+ let request = only(&fake);
+ assert!(request.path.ends_with("/listS3files"));
+ assert!(!request.fields.contains_key("bucket"));
+ zb.list_s3_files(Some("inbox"), Some("csv")).unwrap();
+ let request = only(&fake);
+ assert_eq!(
+ request.fields.get("bucket").map(String::as_str),
+ Some("inbox")
+ );
+ assert_eq!(
+ request.fields.get("search").map(String::as_str),
+ Some("csv")
+ );
+}
+
+#[test]
+fn invalid_arguments_are_rejected_before_a_request() {
+ let (port, fake) = start_fake();
+ let empty = client(port, "");
+ assert!(empty
+ .get_credits()
+ .unwrap_err()
+ .to_string()
+ .contains("api_key"));
+ assert!(empty
+ .find_email_v2()
+ .domain("example.com")
+ .first_name("Ada")
+ .call()
+ .unwrap_err()
+ .to_string()
+ .contains("api_key"));
+ assert!(empty
+ .domain_search_v2()
+ .company_name("ZeroBounce")
+ .call()
+ .unwrap_err()
+ .to_string()
+ .contains("api_key"));
+ assert!(empty
+ .list_filters(false)
+ .unwrap_err()
+ .to_string()
+ .contains("api_key"));
+ assert!(empty
+ .send_domain_search_file(1, "d.csv", &b"a\n"[..])
+ .unwrap_err()
+ .to_string()
+ .contains("api_key"));
+ assert_eq!(fake.len(), 0);
+
+ let zb = client(port, "keyed");
+ let mut bad = ZBValidateOptions::default();
+ bad.timeout = Some(2);
+ assert!(zb
+ .validate_email_with_options("a@example.com", "", &bad)
+ .unwrap_err()
+ .to_string()
+ .contains("timeout"));
+ let mut batch = ZBValidateOptions::default();
+ batch.timeout = Some(9);
+ assert!(zb
+ .batch_validate_with_options(vec![("a@example.com".into(), String::new())], &batch)
+ .unwrap_err()
+ .to_string()
+ .contains("timeout"));
+ assert!(zb
+ .send_email_finder_file(0, "f.csv", &b"a\n"[..])
+ .unwrap_err()
+ .to_string()
+ .contains("domain_column"));
+ assert!(zb
+ .validate_s3_file(" ")
+ .unwrap_err()
+ .to_string()
+ .contains("file_name"));
+ assert_eq!(fake.len(), 0);
+}
+
+#[test]
+fn error_bodies_stay_short_and_batch_account_errors_fail() {
+ let (port, _) = start_fake();
+ let err = client(port, "html-403-user").get_credits().unwrap_err();
+ assert_short(&err);
+ assert!(err.to_string().contains("Access denied"));
+ assert!(html_page().len() > 100_000);
+
+ let err = client(port, "html-200-user")
+ .validate_email("a@example.com")
+ .unwrap_err();
+ assert_short(&err);
+ assert!(err.to_string().contains("Access denied"));
+
+ let err = client(port, "html-200-file")
+ .bulk_validation_result_fetch("fid")
+ .unwrap_err();
+ assert_short(&err);
+ assert!(err.to_string().contains("Access denied"));
+
+ let err = client(port, "cf-json-user").get_credits().unwrap_err();
+ assert_short(&err);
+ assert!(!err.to_string().contains("developers.cloudflare.com"));
+ assert!(err.to_string().contains("Access denied"));
+
+ let err = client(port, "empty-user")
+ .validate_email("a@example.com")
+ .unwrap_err();
+ assert_short(&err);
+ assert!(err.to_string().contains("Empty response"));
+
+ let err = client(port, "malformed-user")
+ .validate_email("a@example.com")
+ .unwrap_err();
+ assert_short(&err);
+ assert!(err.to_string().contains("not valid JSON"));
+ assert!(!err.to_string().contains("<<>>"));
+
+ let err = client(port, "double-error-user")
+ .get_activity_data("a@example.com")
+ .unwrap_err();
+ assert_short(&err);
+ assert_eq!(err.to_string(), "api_key is invalid");
+
+ let err = client(port, "batch-account-user")
+ .batch_validate(vec![("a@example.com".into(), String::new())])
+ .unwrap_err();
+ assert_short(&err);
+ assert!(err.to_string().contains("Invalid API Key"));
+ assert!(err.to_string().contains("ran out of credits"));
+
+ let partial = client(port, "batch-partial-user")
+ .batch_validate(vec![
+ ("good@example.com".into(), String::new()),
+ ("bad@".into(), String::new()),
+ ])
+ .unwrap();
+ assert_eq!(partial.email_batch.len(), 1);
+ assert_eq!(partial.email_batch[0].address, "good@example.com");
+ assert_eq!(partial.errors.len(), 1);
+ assert_eq!(partial.errors[0].email_address, "bad@");
+}
+
+#[test]
+fn concurrent_clients_do_not_share_keys() {
+ let (port, fake) = start_fake();
+ let mut handles = Vec::new();
+ for i in 0..12 {
+ let port = port;
+ handles.push(thread::spawn(move || {
+ let key = format!("conc-{i}");
+ let response = client(port, &key)
+ .validate_email("person@example.com")
+ .unwrap();
+ (key, response.address)
+ }));
+ }
+ let mut echoes = Vec::new();
+ for handle in handles {
+ echoes.push(handle.join().unwrap());
+ }
+ thread::sleep(Duration::from_millis(50));
+ let records = fake.take();
+ assert_eq!(records.len(), 12);
+ for (key, address) in echoes {
+ assert_eq!(address, key);
+ assert_eq!(
+ records
+ .iter()
+ .filter(|record| record.api_key == key)
+ .count(),
+ 1
+ );
+ }
+}
diff --git a/tests/validation/batch_validation.rs b/tests/validation/batch_validation.rs
index 0543872..58cfbc5 100644
--- a/tests/validation/batch_validation.rs
+++ b/tests/validation/batch_validation.rs
@@ -37,9 +37,9 @@ fn test_batch_validation_invalid_json() {
mock.assert();
let zb_error = validation.unwrap_err();
- let ZBError::JsonError(_) = zb_error else {
- panic!("unexpected error: {:#?}", zb_error);
- };
+ let message = zb_error.to_string();
+ assert!(message.len() < 500, "{message}");
+ assert!(!message.contains("expected"), "{message}");
}
#[test]
diff --git a/tests/validation/simple_validation.rs b/tests/validation/simple_validation.rs
index 780cd7c..18f62bf 100644
--- a/tests/validation/simple_validation.rs
+++ b/tests/validation/simple_validation.rs
@@ -29,9 +29,9 @@ fn test_simple_validation_invalid_json() {
mock.assert();
let zb_error = validation.unwrap_err();
- let ZBError::JsonError(_) = zb_error else {
- panic!("unexpected error: {:#?}", zb_error);
- };
+ let message = zb_error.to_string();
+ assert!(message.len() < 500, "{message}");
+ assert!(!message.contains("expected"), "{message}");
}
#[test]