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 01/17] 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 02/17] 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 03/17] 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 04/17] feat: POST /validate transport parity and listS3files
Add form-encoded POST option on single validate (alongside GET) and GET
listS3files on bulkapi root, with unit tests and API parity doc updates.
---
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 05/17] 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 06/17] 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]
From 3acd50409fcb0e8ecbbea058d9200189bf243aae Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Fri, 23 Oct 2026 23:15:13 +0400
Subject: [PATCH 07/17] feat: validate WHOIS and greylist options, Inbox
Placement Tester, list evaluator base URL
Ports the Python SDK's parity changes (407f0a8, a6710e5) to this SDK.
- ZBValidateOptions takes domain_info, greylist_processing and callback_url,
sent as the strings "true"/"false" on both the query and the form POST.
callback_url without greylist_processing: Some(true), and a blank one, are
rejected before any request goes out.
- ZBValidation carries domain_website_exists, domain_registrant_company_name
and message, the last renamed from the API's "Message" key. A greylist pickup
that is still running carries no processed_at or free_email, so both now
default rather than failing the whole response.
- Inbox Placement Tester in src/api/inbox_placement.rs on
bulkapi.zerobounce.net/api/ipt: providers, test types, create/get/list/delete
tests, and get/set webhook. The API key goes on the query string, bodies are
camelCase JSON, and only fields the caller set are sent. reqwest's json()
helper needs a feature this crate does not enable, so the body and content
type are set directly rather than widening the dependency.
- ZBUrlProvider gains list_evaluator_url and inbox_placement_url. List
evaluator requests use the former instead of building the path from the bulk
base, and the file id is URL-encoded as one path segment by a small local
encoder rather than a new crate. send_evaluated_file stays on the bulk base.
- An error body carrying a "detail" string (Django REST framework, used by the
Inbox Placement Tester) returns that text.
Removes validate_s3_file, score_s3_file, export_s3_file and list_s3_files along
with the S3ListFileEntry struct, the post_bulk_root_form helper, tests and
changelog entry: https://www.zerobounce.net/docs/ does not document those
endpoints, and AGENTS.md now records that the SDK covers only documented ones.
The offline test client now also points list_evaluator_url and
inbox_placement_url at the fake server; without that the suite reached the live
API, which is how the list evaluator test first failed.
Tests: 130 pass across the four suites. Clippy reports no errors.
Co-Authored-By: Claude Opus 5
---
AGENTS.md | 7 +-
CHANGELOG.md | 10 +-
README.md | 79 ++++++
documentation.md | 79 ++++++
src/api/inbox_placement.rs | 270 +++++++++++++++++++
src/api/mod.rs | 1 +
src/api/priority3.rs | 98 +------
src/api/validation.rs | 39 +++
src/lib.rs | 12 +
src/utility/http_error.rs | 3 +-
src/utility/mod.rs | 21 ++
src/utility/structures/validate_options.rs | 6 +
src/utility/structures/validation.rs | 17 +-
tests/common/mod.rs | 7 +-
tests/offline_api.rs | 295 ++++++++++++++++++---
15 files changed, 812 insertions(+), 132 deletions(-)
create mode 100644 src/api/inbox_placement.rs
diff --git a/AGENTS.md b/AGENTS.md
index 0c59555..ba3eeac 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,8 @@ Guidance for AI coding agents working in this repository.
- `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/api/priority3.rs`: bulk email finder, domain search, list evaluator, filters. Finder and domain search paths are on the bulk host without `/v2`.
+- `src/api/inbox_placement.rs`: Inbox Placement Tester.
- `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.
@@ -27,6 +28,10 @@ cargo clippy --all-targets
## Conventions
+- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server.
+- **The list evaluator and the Inbox Placement Tester have their own base URLs** on `ZBUrlProvider` (`list_evaluator_url`, `inbox_placement_url`). They are not built from `bulk_url`, and `send_evaluated_file` stays on the bulk base. Offline tests must point both at the fake server or they reach the live API.
+- **The Inbox Placement Tester takes the API key as the `api_key` query parameter and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`.
+
- **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`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbf5b9f..344e752 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,15 +11,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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`).
+- Bulk email finder, bulk domain search, list evaluator, and filters.
+- `ZBValidateOptions` accepts `domain_info`, `greylist_processing`, and `callback_url`. `callback_url` needs `greylist_processing: Some(true)` and is rejected without it, and when blank, before a request is sent.
+- `ZBValidation` carries `domain_website_exists`, `domain_registrant_company_name`, and `message`. `message` is read from the API's `Message` key, returned while greylist processing is still running.
+- Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `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`.
+- `ZBUrlProvider` gains `list_evaluator_url` and `inbox_placement_url`, both overridable.
### Changed
+- List evaluator requests use `ZBUrlProvider::list_evaluator_url` instead of building the path from the bulk base, and the file id is URL-encoded as one path segment. `send_evaluated_file` stays on the bulk base.
+
- 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 error body whose JSON carries a `detail` string (the Inbox Placement Tester, Django REST framework) returns that text.
+
- 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.
diff --git a/README.md b/README.md
index 783982a..2c9df1e 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,85 @@ let zb = ZeroBounce::with_base_url("your_api_key", "https://custom-api.example.c
See the [config_options example](https://github.com/zerobounce/zerobounce-rust-api/tree/main/examples/config_options.rs) for a complete demonstration of all configuration options.
+## Validate options
+
+```rust
+use zero_bounce::utility::structures::validate_options::ZBValidateOptions;
+use zero_bounce::ZeroBounce;
+
+let zb = ZeroBounce::new("");
+
+let options = ZBValidateOptions {
+ // domain_info adds domain_website_exists and domain_registrant_company_name.
+ domain_info: Some(true),
+ // greylist_processing re-runs a greylisted address instead of returning unknown.
+ greylist_processing: Some(true),
+ // callback_url is only allowed together with greylist_processing: Some(true).
+ callback_url: Some("https://example.com/zerobounce-callback".to_string()),
+ ..Default::default()
+};
+
+let validation = zb.validate_email_with_options("someone@example.com", "", &options)?;
+
+// While the greylist retry is still running the API returns a message instead
+// of a result; validate again in about 30 minutes to pick it up.
+println!("{:?} {:?}", validation.message, validation.domain_website_exists);
+```
+
+## Inbox Placement Tester
+
+Lives on `https://bulkapi.zerobounce.net/api/ipt` and spends inbox test credits.
+
+```rust
+use zero_bounce::api::inbox_placement::{
+ CreateInboxPlacementTestOptions, ListInboxPlacementTestsOptions,
+};
+use zero_bounce::ZeroBounce;
+
+let zb = ZeroBounce::new("");
+
+let providers = zb.get_inbox_placement_providers()?;
+let test_types = zb.get_inbox_placement_test_types()?;
+
+// test_type 1 (Email) needs sender_email; 2 is Unique ID and 3 is Auto Alias.
+let created = zb.create_inbox_placement_test(
+ "October campaign", // required, at most 64 characters
+ &CreateInboxPlacementTestOptions {
+ test_type: Some(1),
+ sender_email: Some("news@example.com".to_string()),
+ webhook_url: Some("https://example.com/ipt-webhook".to_string()),
+ ..Default::default()
+ },
+)?;
+
+// Send your campaign to the seeds in `created`, then read the results.
+let result = zb.get_inbox_placement_test("123")?;
+
+let tests = zb.list_inbox_placement_tests(&ListInboxPlacementTestsOptions {
+ status: Some("finished".to_string()),
+ page: Some(1),
+ page_size: Some(25),
+ ..Default::default()
+})?;
+
+zb.set_inbox_placement_webhook("https://example.com/ipt-webhook")?;
+let webhook = zb.get_inbox_placement_webhook()?;
+zb.delete_inbox_placement_test("123")?;
+```
+
+## Per-API base URLs
+
+The list evaluator and the Inbox Placement Tester have their own base URLs,
+separate from the main and bulk hosts.
+
+```rust
+let mut zb = ZeroBounce::new("");
+zb.url_provider.list_evaluator_url =
+ "https://bulkapi.zerobounce.net/v2/listevaluator".to_string();
+zb.url_provider.inbox_placement_url =
+ "https://bulkapi.zerobounce.net/api/ipt".to_string();
+```
+
## Email Finding Methods
### find_email_v2 (Recommended)
diff --git a/documentation.md b/documentation.md
index 526d27e..a6a954a 100644
--- a/documentation.md
+++ b/documentation.md
@@ -51,6 +51,85 @@ let zb = ZeroBounce::with_base_url("your_api_key", "https://custom-api.example.c
See the [config_options example](https://github.com/zerobounce/zerobounce-rust-api/tree/main/examples/config_options.rs) for a complete demonstration of all configuration options.
+## Validate options
+
+```rust
+use zero_bounce::utility::structures::validate_options::ZBValidateOptions;
+use zero_bounce::ZeroBounce;
+
+let zb = ZeroBounce::new("");
+
+let options = ZBValidateOptions {
+ // domain_info adds domain_website_exists and domain_registrant_company_name.
+ domain_info: Some(true),
+ // greylist_processing re-runs a greylisted address instead of returning unknown.
+ greylist_processing: Some(true),
+ // callback_url is only allowed together with greylist_processing: Some(true).
+ callback_url: Some("https://example.com/zerobounce-callback".to_string()),
+ ..Default::default()
+};
+
+let validation = zb.validate_email_with_options("someone@example.com", "", &options)?;
+
+// While the greylist retry is still running the API returns a message instead
+// of a result; validate again in about 30 minutes to pick it up.
+println!("{:?} {:?}", validation.message, validation.domain_website_exists);
+```
+
+## Inbox Placement Tester
+
+Lives on `https://bulkapi.zerobounce.net/api/ipt` and spends inbox test credits.
+
+```rust
+use zero_bounce::api::inbox_placement::{
+ CreateInboxPlacementTestOptions, ListInboxPlacementTestsOptions,
+};
+use zero_bounce::ZeroBounce;
+
+let zb = ZeroBounce::new("");
+
+let providers = zb.get_inbox_placement_providers()?;
+let test_types = zb.get_inbox_placement_test_types()?;
+
+// test_type 1 (Email) needs sender_email; 2 is Unique ID and 3 is Auto Alias.
+let created = zb.create_inbox_placement_test(
+ "October campaign", // required, at most 64 characters
+ &CreateInboxPlacementTestOptions {
+ test_type: Some(1),
+ sender_email: Some("news@example.com".to_string()),
+ webhook_url: Some("https://example.com/ipt-webhook".to_string()),
+ ..Default::default()
+ },
+)?;
+
+// Send your campaign to the seeds in `created`, then read the results.
+let result = zb.get_inbox_placement_test("123")?;
+
+let tests = zb.list_inbox_placement_tests(&ListInboxPlacementTestsOptions {
+ status: Some("finished".to_string()),
+ page: Some(1),
+ page_size: Some(25),
+ ..Default::default()
+})?;
+
+zb.set_inbox_placement_webhook("https://example.com/ipt-webhook")?;
+let webhook = zb.get_inbox_placement_webhook()?;
+zb.delete_inbox_placement_test("123")?;
+```
+
+## Per-API base URLs
+
+The list evaluator and the Inbox Placement Tester have their own base URLs,
+separate from the main and bulk hosts.
+
+```rust
+let mut zb = ZeroBounce::new("");
+zb.url_provider.list_evaluator_url =
+ "https://bulkapi.zerobounce.net/v2/listevaluator".to_string();
+zb.url_provider.inbox_placement_url =
+ "https://bulkapi.zerobounce.net/api/ipt".to_string();
+```
+
## Email Finding Methods
### find_email_v2 (Recommended)
diff --git a/src/api/inbox_placement.rs b/src/api/inbox_placement.rs
new file mode 100644
index 0000000..48da843
--- /dev/null
+++ b/src/api/inbox_placement.rs
@@ -0,0 +1,270 @@
+//! Inbox Placement Tester (bulkapi.zerobounce.net/api/ipt).
+//!
+//! The API key goes on the query string rather than in the body, and bodies are
+//! JSON with camelCase fields. Only fields the caller set are sent. Uses the
+//! account's inbox-test credits.
+
+use std::collections::HashMap;
+
+use serde_json::{json, Map, Value};
+
+use crate::utility::{encode_path_segment, ZBError, ZBResult};
+use crate::ZeroBounce;
+
+const TEST_TYPES: [u8; 3] = [1, 2, 3];
+const TEST_STATUSES: [&str; 2] = ["running", "finished"];
+
+/// A per-provider seed cap for [`ZeroBounce::create_inbox_placement_test`].
+#[derive(Clone, Debug)]
+pub struct InboxPlacementProviderSelection {
+ pub id: i64,
+ /// At least 1 when given.
+ pub max_seeds: Option,
+}
+
+/// Options for [`ZeroBounce::create_inbox_placement_test`]. Unset fields are not sent.
+#[derive(Clone, Debug, Default)]
+pub struct CreateInboxPlacementTestOptions {
+ /// 1 (Email, needs `sender_email`), 2 (Unique ID) or 3 (Auto Alias).
+ pub test_type: Option,
+ /// The From address to match; required when `test_type` is 1.
+ pub sender_email: Option,
+ /// Use every active seed of these providers.
+ pub provider_ids: Option>,
+ /// Use these seed mailboxes.
+ pub seed_ids: Option>,
+ /// Cap the seeds taken from each provider.
+ pub providers: Option>,
+ /// Where the finished result is posted.
+ pub webhook_url: Option,
+}
+
+/// Filters for [`ZeroBounce::list_inbox_placement_tests`]. Unset fields are not sent.
+#[derive(Clone, Debug, Default)]
+pub struct ListInboxPlacementTestsOptions {
+ /// "running" or "finished".
+ pub status: Option,
+ /// ISO 8601 date or datetime.
+ pub created_after: Option,
+ /// ISO 8601 date or datetime.
+ pub created_before: Option,
+ /// From 1.
+ pub page: Option,
+ /// 1 to 100.
+ pub page_size: Option,
+}
+
+impl ZeroBounce {
+ /// Seed providers available for new tests. `GET /providers`
+ pub fn get_inbox_placement_providers(&self) -> ZBResult {
+ self.inbox_placement_get("/providers", HashMap::new())
+ }
+
+ /// Supported test types and the default one. `GET /test-types`
+ pub fn get_inbox_placement_test_types(&self) -> ZBResult {
+ self.inbox_placement_get("/test-types", HashMap::new())
+ }
+
+ /// Create an inbox placement test. `POST /test`
+ ///
+ /// `name` must be non-empty and at most 64 characters. Spends one inbox test.
+ pub fn create_inbox_placement_test(
+ &self,
+ name: &str,
+ options: &CreateInboxPlacementTestOptions,
+ ) -> ZBResult {
+ if name.trim().is_empty() {
+ return Err(ZBError::explicit("Empty parameter: name"));
+ }
+ if name.chars().count() > 64 {
+ return Err(ZBError::explicit(
+ "Invalid parameter: name (at most 64 characters)",
+ ));
+ }
+ if let Some(test_type) = options.test_type {
+ if !TEST_TYPES.contains(&test_type) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: test_type (must be 1, 2 or 3)",
+ ));
+ }
+ if test_type == 1
+ && options
+ .sender_email
+ .as_deref()
+ .map(|value| value.trim().is_empty())
+ .unwrap_or(true)
+ {
+ return Err(ZBError::explicit(
+ "Empty parameter: sender_email (required when test_type is 1)",
+ ));
+ }
+ }
+ for provider in options.providers.iter().flatten() {
+ if provider.max_seeds.map(|seeds| seeds < 1).unwrap_or(false) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: providers (max_seeds must be at least 1)",
+ ));
+ }
+ }
+
+ let mut body = Map::new();
+ body.insert("name".to_string(), json!(name));
+ if let Some(test_type) = options.test_type {
+ body.insert("testType".to_string(), json!(test_type));
+ }
+ if let Some(sender_email) = options.sender_email.as_deref() {
+ body.insert("senderEmail".to_string(), json!(sender_email));
+ }
+ if let Some(provider_ids) = options.provider_ids.as_ref() {
+ body.insert("providerIds".to_string(), json!(provider_ids));
+ }
+ if let Some(seed_ids) = options.seed_ids.as_ref() {
+ body.insert("seedIds".to_string(), json!(seed_ids));
+ }
+ if let Some(providers) = options.providers.as_ref() {
+ let entries: Vec = providers
+ .iter()
+ .map(|provider| match provider.max_seeds {
+ Some(max_seeds) => json!({"id": provider.id, "maxSeeds": max_seeds}),
+ None => json!({"id": provider.id}),
+ })
+ .collect();
+ body.insert("providers".to_string(), Value::Array(entries));
+ }
+ if let Some(webhook_url) = options.webhook_url.as_deref() {
+ body.insert("webhookUrl".to_string(), json!(webhook_url));
+ }
+ self.inbox_placement_json("POST", "/test", Value::Object(body))
+ }
+
+ /// A test and its placement results. `GET /test/{id}`
+ pub fn get_inbox_placement_test(&self, test_id: &str) -> ZBResult {
+ let path = format!("/test/{}", Self::inbox_placement_test_path(test_id)?);
+ self.inbox_placement_get(&path, HashMap::new())
+ }
+
+ /// List tests, newest first. `GET /tests`
+ pub fn list_inbox_placement_tests(
+ &self,
+ options: &ListInboxPlacementTestsOptions,
+ ) -> ZBResult {
+ if let Some(status) = options.status.as_deref() {
+ if !TEST_STATUSES.contains(&status) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: status (must be running or finished)",
+ ));
+ }
+ }
+ if options.page.map(|page| page < 1).unwrap_or(false) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: page (must be at least 1)",
+ ));
+ }
+ if options
+ .page_size
+ .map(|size| !(1..=100).contains(&size))
+ .unwrap_or(false)
+ {
+ return Err(ZBError::explicit(
+ "Invalid parameter: page_size (must be between 1 and 100)",
+ ));
+ }
+
+ let page_param;
+ let page_size_param;
+ let mut query: HashMap<&str, &str> = HashMap::new();
+ if let Some(status) = options.status.as_deref() {
+ query.insert("status", status);
+ }
+ if let Some(created_after) = options.created_after.as_deref() {
+ query.insert("createdAfter", created_after);
+ }
+ if let Some(created_before) = options.created_before.as_deref() {
+ query.insert("createdBefore", created_before);
+ }
+ if let Some(page) = options.page {
+ page_param = page.to_string();
+ query.insert("page", &page_param);
+ }
+ if let Some(page_size) = options.page_size {
+ page_size_param = page_size.to_string();
+ query.insert("page_size", &page_size_param);
+ }
+ self.inbox_placement_get("/tests", query)
+ }
+
+ /// Delete a finished test. `DELETE /test/{id}`
+ pub fn delete_inbox_placement_test(&self, test_id: &str) -> ZBResult {
+ let path = format!("/test/{}", Self::inbox_placement_test_path(test_id)?);
+ self.inbox_placement_request("DELETE", &path, HashMap::new(), None)
+ }
+
+ /// The webhook URL saved on this API key. `GET /webhook`
+ pub fn get_inbox_placement_webhook(&self) -> ZBResult {
+ self.inbox_placement_get("/webhook", HashMap::new())
+ }
+
+ /// Save the URL finished results are posted to. `PUT /webhook`
+ pub fn set_inbox_placement_webhook(&self, webhook_url: &str) -> ZBResult {
+ if webhook_url.trim().is_empty() {
+ return Err(ZBError::explicit("Empty parameter: webhook_url"));
+ }
+ self.inbox_placement_json("PUT", "/webhook", json!({ "webhookUrl": webhook_url }))
+ }
+
+ fn inbox_placement_test_path(test_id: &str) -> ZBResult {
+ if test_id.trim().is_empty() {
+ return Err(ZBError::explicit("Empty parameter: test_id"));
+ }
+ Ok(encode_path_segment(test_id.trim()))
+ }
+
+ fn inbox_placement_get<'a>(
+ &'a self,
+ path: &str,
+ query: HashMap<&'a str, &'a str>,
+ ) -> ZBResult {
+ self.inbox_placement_request("GET", path, query, None)
+ }
+
+ fn inbox_placement_json(&self, method: &str, path: &str, body: Value) -> ZBResult {
+ self.inbox_placement_request(method, path, HashMap::new(), Some(body))
+ }
+
+ fn inbox_placement_request<'a>(
+ &'a self,
+ method: &str,
+ path: &str,
+ mut query: HashMap<&'a str, &'a str>,
+ body: Option,
+ ) -> ZBResult {
+ self.require_key()?;
+ query.insert("api_key", self.api_key.as_str());
+
+ let url = self.url_provider.inbox_placement_url_of(path);
+ let parsed = reqwest::Url::parse(&url)
+ .map_err(|_| ZBError::explicit("Invalid parameter: inbox placement base URL"))?;
+
+ let mut request = match method {
+ "POST" => self.client.post(parsed),
+ "PUT" => self.client.put(parsed),
+ "DELETE" => self.client.delete(parsed),
+ _ => self.client.get(parsed),
+ }
+ .query(&query);
+
+ if let Some(body) = body {
+ // reqwest's json() helper needs its "json" feature, which this crate
+ // does not enable, so the body and content type are set directly.
+ let encoded = serde_json::to_string(&body)
+ .map_err(|_| ZBError::explicit("Invalid parameter: request body"))?;
+ request = request
+ .header(reqwest::header::CONTENT_TYPE, "application/json")
+ .body(encoded);
+ }
+
+ let response = request.send()?;
+ let content = self.read_api_response(response)?;
+ crate::utility::http_error::parse_json_body(&content)
+ }
+}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 45751e3..ee6b73b 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -1,4 +1,5 @@
pub mod bulk;
+pub mod inbox_placement;
pub mod priority3;
pub mod validation;
diff --git a/src/api/priority3.rs b/src/api/priority3.rs
index cf1d762..7850df6 100644
--- a/src/api/priority3.rs
+++ b/src/api/priority3.rs
@@ -2,7 +2,6 @@ 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::utility::structures::bulk::ZBFileFeedback;
@@ -38,34 +37,6 @@ fn post_bulk_root_multipart(
crate::utility::http_error::parse_json_body(&body)
}
-fn post_bulk_root_form(
- client: &ZeroBounce,
- path: &str,
- fields: HashMap,
-) -> ZBResult {
- 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 body = client.read_api_response(response)?;
- crate::utility::http_error::parse_json_body(&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",
@@ -158,8 +129,8 @@ impl ZeroBounce {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
let url = format!(
- "{}/v2/listevaluator/",
- bulk_api_root(&self.url_provider.bulk_url)
+ "{}/",
+ self.url_provider.list_evaluator_url
);
post_bulk_root_multipart(
self,
@@ -178,9 +149,9 @@ impl ZeroBounce {
return Err(ZBError::explicit("empty parameter: file_id"));
}
let url = format!(
- "{}/v2/listevaluator/{}/",
- bulk_api_root(&self.url_provider.bulk_url),
- file_id
+ "{}/{}/",
+ self.url_provider.list_evaluator_url,
+ crate::utility::encode_path_segment(file_id)
);
let body = self.generic_get_request(url, HashMap::new())?;
crate::utility::http_error::parse_json_body(&body)
@@ -214,63 +185,4 @@ 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"));
- }
- 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()),
- ]),
- )
- }
}
diff --git a/src/api/validation.rs b/src/api/validation.rs
index 395713a..635d9a1 100644
--- a/src/api/validation.rs
+++ b/src/api/validation.rs
@@ -26,10 +26,25 @@ impl ZeroBounce {
));
}
}
+ // callback_url only means anything while greylist processing is on, so it is
+ // rejected here rather than letting the API take the request.
+ if let Some(callback_url) = options.callback_url.as_deref() {
+ if options.greylist_processing != Some(true) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: callback_url requires greylist_processing = true",
+ ));
+ }
+ if callback_url.trim().is_empty() {
+ return Err(ZBError::explicit("Empty parameter: callback_url"));
+ }
+ }
+
let mut query_args = HashMap::from([("email", email)]);
let timeout_param;
let activity_param;
let verify_param;
+ let domain_info_param;
+ let greylist_param;
if !ip_address.is_empty() {
query_args.insert("ip_address", ip_address);
@@ -46,12 +61,25 @@ impl ZeroBounce {
verify_param = if verify_plus { "true" } else { "false" };
query_args.insert("verify_plus", verify_param);
}
+ if let Some(domain_info) = options.domain_info {
+ domain_info_param = if domain_info { "true" } else { "false" };
+ query_args.insert("domain_info", domain_info_param);
+ }
+ if let Some(greylist_processing) = options.greylist_processing {
+ greylist_param = if greylist_processing { "true" } else { "false" };
+ query_args.insert("greylist_processing", greylist_param);
+ }
+ if let Some(callback_url) = options.callback_url.as_deref() {
+ query_args.insert("callback_url", callback_url);
+ }
let response_content = if options.use_post {
let mut form_args = HashMap::from([("email", email)]);
let timeout_param;
let activity_param;
let verify_param;
+ let domain_info_param;
+ let greylist_param;
if !ip_address.is_empty() {
form_args.insert("ip_address", ip_address);
}
@@ -67,6 +95,17 @@ impl ZeroBounce {
verify_param = if verify_plus { "true" } else { "false" };
form_args.insert("verify_plus", verify_param);
}
+ if let Some(domain_info) = options.domain_info {
+ domain_info_param = if domain_info { "true" } else { "false" };
+ form_args.insert("domain_info", domain_info_param);
+ }
+ if let Some(greylist_processing) = options.greylist_processing {
+ greylist_param = if greylist_processing { "true" } else { "false" };
+ form_args.insert("greylist_processing", greylist_param);
+ }
+ if let Some(callback_url) = options.callback_url.as_deref() {
+ form_args.insert("callback_url", callback_url);
+ }
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)?
diff --git a/src/lib.rs b/src/lib.rs
index 737eaaf..395d879 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -18,6 +18,10 @@ pub use crate::utility::{ApiBaseUrl, ZBError, ZBResult};
pub struct ZBUrlProvider {
pub url: String,
pub bulk_url: String,
+ /// Overridable; the list evaluator path differs from the other bulk endpoints.
+ pub list_evaluator_url: String,
+ /// Overridable; the Inbox Placement Tester is not under the bulk /v2 path.
+ pub inbox_placement_url: String,
}
impl ZBUrlProvider {
@@ -27,6 +31,12 @@ impl ZBUrlProvider {
pub fn bulk_url_of(&self, endpoint: &str) -> String {
self.bulk_url.to_owned() + endpoint
}
+ pub fn list_evaluator_url_of(&self, endpoint: &str) -> String {
+ self.list_evaluator_url.to_owned() + endpoint
+ }
+ pub fn inbox_placement_url_of(&self, endpoint: &str) -> String {
+ self.inbox_placement_url.to_owned() + endpoint
+ }
}
impl Default for ZBUrlProvider {
@@ -34,6 +44,8 @@ impl Default for ZBUrlProvider {
ZBUrlProvider {
url: ApiBaseUrl::Default.as_str().to_string(),
bulk_url: crate::utility::BULK_URI.to_string(),
+ list_evaluator_url: crate::utility::LIST_EVALUATOR_URI.to_string(),
+ inbox_placement_url: crate::utility::INBOX_PLACEMENT_URI.to_string(),
}
}
}
diff --git a/src/utility/http_error.rs b/src/utility/http_error.rs
index 7ad50f3..54c6436 100644
--- a/src/utility/http_error.rs
+++ b/src/utility/http_error.rs
@@ -49,7 +49,8 @@ fn html_title(body: &str) -> String {
}
fn first_api_error_field(object: &serde_json::Map) -> String {
- for key in ["error", "message", "error_message", "Message"] {
+ // "detail" is how the Inbox Placement Tester (Django REST framework) reports errors.
+ for key in ["error", "message", "error_message", "Message", "detail"] {
match object.get(key) {
Some(serde_json::Value::String(text)) if !text.trim().is_empty() => {
return text.trim().to_string();
diff --git a/src/utility/mod.rs b/src/utility/mod.rs
index 72ec451..03c5cfc 100644
--- a/src/utility/mod.rs
+++ b/src/utility/mod.rs
@@ -48,7 +48,28 @@ impl From<&ApiBaseUrl> for String {
}
}
+/// Percent-encodes a value so it is one path segment.
+///
+/// Only the unreserved set from RFC 3986 is left alone, so `/`, spaces and the
+/// rest are escaped and a file id cannot break out of its segment. Written here
+/// rather than pulling in a crate for four lines.
+pub fn encode_path_segment(value: &str) -> String {
+ let mut encoded = String::with_capacity(value.len());
+ for byte in value.as_bytes() {
+ match byte {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
+ encoded.push(*byte as char)
+ }
+ _ => encoded.push_str(&format!("%{:02X}", byte)),
+ }
+ }
+ encoded
+}
+
pub const BULK_URI: &str = "https://bulkapi.zerobounce.net/v2";
+/// The list evaluator and the Inbox Placement Tester are not under the bulk /v2 path.
+pub const LIST_EVALUATOR_URI: &str = "https://bulkapi.zerobounce.net/v2/listevaluator";
+pub const INBOX_PLACEMENT_URI: &str = "https://bulkapi.zerobounce.net/api/ipt";
pub const ENDPOINT_CREDITS: &str = "/getcredits";
pub const ENDPOINT_ACTIVITY_DATA: &str = "/activity";
pub const ENDPOINT_SCORING: &str = "/scoring";
diff --git a/src/utility/structures/validate_options.rs b/src/utility/structures/validate_options.rs
index 637ffb3..b57a02d 100644
--- a/src/utility/structures/validate_options.rs
+++ b/src/utility/structures/validate_options.rs
@@ -3,5 +3,11 @@ pub struct ZBValidateOptions {
pub timeout: Option,
pub activity_data: Option,
pub verify_plus: Option,
+ /// Adds `domain_website_exists` and `domain_registrant_company_name` to the response.
+ pub domain_info: Option,
+ /// Processes a greylisted address again instead of returning it as unknown.
+ pub greylist_processing: Option,
+ /// Where the result is sent once ready. Only valid with `greylist_processing: Some(true)`.
+ pub callback_url: Option,
pub use_post: bool,
}
diff --git a/src/utility/structures/validation.rs b/src/utility/structures/validation.rs
index 8c541b5..fd4a01c 100644
--- a/src/utility/structures/validation.rs
+++ b/src/utility/structures/validation.rs
@@ -10,6 +10,7 @@ pub struct ZBValidation {
pub address: String,
pub status: String,
pub sub_status: String,
+ #[serde(default)]
pub free_email: bool,
pub did_you_mean: Option,
pub account: Option,
@@ -29,7 +30,21 @@ pub struct ZBValidation {
pub city: Option,
pub zipcode: Option,
- #[serde(deserialize_with="deserialize_naive_date")]
+ /// With `domain_info`: whether the domain has a website ("True", "False" or "unchecked").
+ pub domain_website_exists: Option,
+
+ /// With `domain_info`: the WHOIS registrant company, "Registrant info not found"
+ /// or "unchecked".
+ pub domain_registrant_company_name: Option,
+
+ /// Set instead of a result while greylist processing is still running; validate
+ /// again later to pick it up. The API spells this key "Message".
+ #[serde(rename = "Message")]
+ pub message: Option,
+
+ /// A greylist pickup that is still running carries no timestamp, so this
+ /// defaults rather than failing the whole response.
+ #[serde(default, deserialize_with="deserialize_naive_date")]
pub processed_at: NaiveDateTime,
}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 86a38b3..85a5dd2 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -12,7 +12,10 @@ pub(crate) fn instantiate<'s>() -> (ServerGuard, ZeroBounce) {
let mock_url: String = mock_server.url().to_owned();
let mock_url_provider = ZBUrlProvider {
- url: mock_url.clone(), bulk_url: mock_url.clone()
+ url: mock_url.clone(),
+ bulk_url: mock_url.clone(),
+ list_evaluator_url: mock_url.clone(),
+ inbox_placement_url: mock_url.clone(),
};
let zb_instance = ZeroBounce {
api_key: MOCK_API_KEY.to_string().clone(),
@@ -35,6 +38,8 @@ pub(crate) fn invalid_url_zb_instance() -> ZeroBounce {
url_provider: ZBUrlProvider {
url: INVALID_URL.to_owned(),
bulk_url: INVALID_URL.to_owned(),
+ list_evaluator_url: INVALID_URL.to_owned(),
+ inbox_placement_url: INVALID_URL.to_owned(),
},
}
}
diff --git a/tests/offline_api.rs b/tests/offline_api.rs
index cda611e..a4fe552 100644
--- a/tests/offline_api.rs
+++ b/tests/offline_api.rs
@@ -10,6 +10,10 @@ use zero_bounce::utility::structures::bulk::{
zb_download_type, ZBBulkResponse, ZBFile, ZBGetFileOptions,
};
use zero_bounce::utility::structures::validate_options::ZBValidateOptions;
+use zero_bounce::api::inbox_placement::{
+ CreateInboxPlacementTestOptions, InboxPlacementProviderSelection,
+ ListInboxPlacementTestsOptions,
+};
use zero_bounce::utility::BULK_URI;
use zero_bounce::{ApiBaseUrl, ZeroBounce};
@@ -55,11 +59,19 @@ fn success_body(path: &str, key: &str) -> (u16, &'static str, String) {
format!("email\n{key}@example.com\n"),
);
}
- if path.contains("listS3files") {
+ if path.contains("/api/ipt") {
+ if key.starts_with("ipt-detail") {
+ // The Inbox Placement Tester (Django REST framework) reports "detail".
+ return (
+ 404,
+ "application/json",
+ r#"{"detail":"Inbox placement test not found."}"#.to_string(),
+ );
+ }
return (
200,
"application/json",
- format!(r#"[{{"name":{quoted},"size":1,"last_modified":""}}]"#),
+ format!(r#"{{"ok":true,"echo_api_key":{quoted}}}"#),
);
}
if path.contains("validatebatch") {
@@ -372,6 +384,10 @@ fn client(port: u16, key: &str) -> ZeroBounce {
zb.base_url = format!("http://127.0.0.1:{port}/api/v2");
zb.url_provider.url = format!("http://127.0.0.1:{port}/api/v2");
zb.url_provider.bulk_url = format!("http://127.0.0.1:{port}/bulk/v2");
+ // These default to the live hosts, so they must be pointed at the fake too or
+ // the suite would reach the real API.
+ zb.url_provider.list_evaluator_url = format!("http://127.0.0.1:{port}/bulk/v2/listevaluator");
+ zb.url_provider.inbox_placement_url = format!("http://127.0.0.1:{port}/api/ipt");
zb.client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
@@ -735,33 +751,6 @@ fn files_scoring_and_priority3() {
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]
@@ -820,11 +809,6 @@ fn invalid_arguments_are_rejected_before_a_request() {
.unwrap_err()
.to_string()
.contains("domain_column"));
- assert!(zb
- .validate_s3_file(" ")
- .unwrap_err()
- .to_string()
- .contains("file_name"));
assert_eq!(fake.len(), 0);
}
@@ -923,3 +907,246 @@ fn concurrent_clients_do_not_share_keys() {
);
}
}
+
+#[test]
+fn validate_sends_the_new_options_only_when_set() {
+ let (port, fake) = start_fake();
+ let zb = client(port, "parity-key");
+
+ zb.validate_email("a@example.com").unwrap();
+ let request = only(&fake);
+ assert!(!request.fields.contains_key("domain_info"));
+ assert!(!request.fields.contains_key("greylist_processing"));
+ assert!(!request.fields.contains_key("callback_url"));
+
+ let options = ZBValidateOptions {
+ domain_info: Some(true),
+ greylist_processing: Some(false),
+ ..Default::default()
+ };
+ zb.validate_email_with_options("a@example.com", "", &options)
+ .unwrap();
+ let request = only(&fake);
+ assert_eq!(
+ request.fields.get("domain_info").map(String::as_str),
+ Some("true")
+ );
+ assert_eq!(
+ request.fields.get("greylist_processing").map(String::as_str),
+ Some("false")
+ );
+
+ let options = ZBValidateOptions {
+ greylist_processing: Some(true),
+ callback_url: Some("https://example.com/cb".to_string()),
+ use_post: true,
+ ..Default::default()
+ };
+ zb.validate_email_with_options("a@example.com", "", &options)
+ .unwrap();
+ let request = only(&fake);
+ assert_eq!(request.method, "POST");
+ assert_eq!(
+ request.fields.get("callback_url").map(String::as_str),
+ Some("https://example.com/cb")
+ );
+}
+
+#[test]
+fn callback_url_without_greylist_processing_sends_no_request() {
+ let (port, fake) = start_fake();
+ let zb = client(port, "parity-key");
+
+ let options = ZBValidateOptions {
+ callback_url: Some("https://example.com/cb".to_string()),
+ ..Default::default()
+ };
+ let error = zb
+ .validate_email_with_options("a@example.com", "", &options)
+ .unwrap_err();
+ assert!(error.to_string().contains("greylist_processing"));
+
+ let blank = ZBValidateOptions {
+ greylist_processing: Some(true),
+ callback_url: Some(" ".to_string()),
+ ..Default::default()
+ };
+ let error = zb
+ .validate_email_with_options("a@example.com", "", &blank)
+ .unwrap_err();
+ assert!(error.to_string().contains("callback_url"));
+ assert_eq!(fake.len(), 0);
+}
+
+#[test]
+fn inbox_placement_uses_its_own_host_and_camel_case_json() {
+ let (port, fake) = start_fake();
+ let zb = client(port, "ipt-key");
+
+ zb.get_inbox_placement_providers().unwrap();
+ let request = only(&fake);
+ assert_eq!(request.method, "GET");
+ assert!(request.path.ends_with("/api/ipt/providers"));
+ assert_eq!(request.api_key, "ipt-key");
+
+ zb.get_inbox_placement_test_types().unwrap();
+ assert!(only(&fake).path.ends_with("/api/ipt/test-types"));
+
+ zb.create_inbox_placement_test("basic", &Default::default())
+ .unwrap();
+ let request = only(&fake);
+ assert_eq!(request.method, "POST");
+ assert!(request.path.ends_with("/api/ipt/test"));
+ assert_eq!(request.fields.get("name").map(String::as_str), Some("basic"));
+ assert!(!request.fields.contains_key("testType"));
+ assert!(!request.fields.contains_key("senderEmail"));
+
+ let options = CreateInboxPlacementTestOptions {
+ test_type: Some(1),
+ sender_email: Some("from@example.com".to_string()),
+ webhook_url: Some("https://example.com/hook".to_string()),
+ ..Default::default()
+ };
+ zb.create_inbox_placement_test("full", &options).unwrap();
+ let request = only(&fake);
+ assert_eq!(request.fields.get("testType").map(String::as_str), Some("1"));
+ assert_eq!(
+ request.fields.get("senderEmail").map(String::as_str),
+ Some("from@example.com")
+ );
+ assert_eq!(
+ request.fields.get("webhookUrl").map(String::as_str),
+ Some("https://example.com/hook")
+ );
+
+ zb.get_inbox_placement_test("a b/c").unwrap();
+ assert!(only(&fake).path.ends_with("/api/ipt/test/a%20b%2Fc"));
+
+ zb.delete_inbox_placement_test("t1").unwrap();
+ assert_eq!(only(&fake).method, "DELETE");
+
+ zb.set_inbox_placement_webhook("https://example.com/hook")
+ .unwrap();
+ let request = only(&fake);
+ assert_eq!(request.method, "PUT");
+ assert_eq!(
+ request.fields.get("webhookUrl").map(String::as_str),
+ Some("https://example.com/hook")
+ );
+}
+
+#[test]
+fn inbox_placement_list_sends_only_the_filters_that_are_set() {
+ let (port, fake) = start_fake();
+ let zb = client(port, "ipt-key");
+
+ zb.list_inbox_placement_tests(&Default::default()).unwrap();
+ let request = only(&fake);
+ assert!(!request.fields.contains_key("status"));
+ assert!(!request.fields.contains_key("page"));
+
+ let options = ListInboxPlacementTestsOptions {
+ status: Some("finished".to_string()),
+ created_after: Some("2026-01-01".to_string()),
+ page: Some(2),
+ page_size: Some(50),
+ ..Default::default()
+ };
+ zb.list_inbox_placement_tests(&options).unwrap();
+ let request = only(&fake);
+ assert_eq!(
+ request.fields.get("status").map(String::as_str),
+ Some("finished")
+ );
+ assert_eq!(
+ request.fields.get("createdAfter").map(String::as_str),
+ Some("2026-01-01")
+ );
+ assert_eq!(request.fields.get("page").map(String::as_str), Some("2"));
+ assert_eq!(
+ request.fields.get("page_size").map(String::as_str),
+ Some("50")
+ );
+}
+
+#[test]
+fn inbox_placement_rejects_invalid_arguments_before_a_request() {
+ let (port, fake) = start_fake();
+ let zb = client(port, "ipt-key");
+
+ assert!(zb
+ .create_inbox_placement_test("", &Default::default())
+ .is_err());
+ assert!(zb
+ .create_inbox_placement_test(&"x".repeat(65), &Default::default())
+ .is_err());
+ assert!(zb
+ .create_inbox_placement_test(
+ "t",
+ &CreateInboxPlacementTestOptions {
+ test_type: Some(4),
+ ..Default::default()
+ }
+ )
+ .is_err());
+ assert!(zb
+ .create_inbox_placement_test(
+ "t",
+ &CreateInboxPlacementTestOptions {
+ test_type: Some(1),
+ ..Default::default()
+ }
+ )
+ .is_err());
+ assert!(zb
+ .create_inbox_placement_test(
+ "t",
+ &CreateInboxPlacementTestOptions {
+ providers: Some(vec![InboxPlacementProviderSelection {
+ id: 1,
+ max_seeds: Some(0),
+ }]),
+ ..Default::default()
+ }
+ )
+ .is_err());
+ assert!(zb
+ .list_inbox_placement_tests(&ListInboxPlacementTestsOptions {
+ status: Some("queued".to_string()),
+ ..Default::default()
+ })
+ .is_err());
+ assert!(zb
+ .list_inbox_placement_tests(&ListInboxPlacementTestsOptions {
+ page_size: Some(101),
+ ..Default::default()
+ })
+ .is_err());
+ assert!(zb.get_inbox_placement_test(" ").is_err());
+ assert!(zb.delete_inbox_placement_test("").is_err());
+ assert!(zb.set_inbox_placement_webhook(" ").is_err());
+ assert_eq!(fake.len(), 0);
+}
+
+#[test]
+fn inbox_placement_detail_error_becomes_the_message() {
+ let (port, _fake) = start_fake();
+ let zb = client(port, "ipt-detail-key");
+
+ let error = zb.get_inbox_placement_providers().unwrap_err();
+ assert!(error.to_string().contains("Inbox placement test not found"));
+ assert_short(&error);
+}
+
+#[test]
+fn list_evaluator_uses_its_own_base_url_and_encodes_file_id() {
+ let (port, fake) = start_fake();
+ let mut zb = client(port, "le-key");
+
+ zb.get_list_evaluator_result("a b/c").unwrap();
+ assert!(only(&fake).path.ends_with("/listevaluator/a%20b%2Fc/"));
+
+ zb.url_provider.list_evaluator_url = format!("http://127.0.0.1:{port}/custom/listevaluator");
+ zb.get_list_evaluator_result("fid").unwrap();
+ assert!(only(&fake).path.ends_with("/custom/listevaluator/fid/"));
+}
From 986b5917813c843d93fb94650413bba5bd7de76d Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Fri, 23 Oct 2026 23:40:33 +0400
Subject: [PATCH 08/17] revert: restore the S3 bulk methods
They are documented, at
https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, which
lists listS3files, validateS3file, scoreS3file, exportS3file,
emailFinderS3file and domainSearchS3file on bulkapi.zerobounce.net. The
previous commit removed them on the incorrect basis that the public docs do
not cover them; only the main API reference was considered.
Restores validate_s3_file, score_s3_file, export_s3_file and list_s3_files,
the S3ListFileEntry struct, the post_bulk_root_form helper, the offline tests
and the changelog entry. The parity work in the previous commit is untouched.
AGENTS.md keeps the rule that the SDK covers only documented endpoints and
now points at where the S3 documentation lives.
Not added here: emailFinderS3file and domainSearchS3file, and the SFTP
variants listed on the Integrations index, are documented but have never been
part of this SDK.
Tests: 130 pass across the four suites.
Co-Authored-By: Claude Opus 5
---
AGENTS.md | 4 +-
CHANGELOG.md | 2 +-
src/api/priority3.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++
tests/offline_api.rs | 39 +++++++++++++++++++
4 files changed, 131 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index ba3eeac..cb74ebd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,7 @@ Guidance for AI coding agents working in this repository.
- `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. Finder and domain search paths are on the bulk host without `/v2`.
+- `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/api/inbox_placement.rs`: Inbox Placement Tester.
- `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.
@@ -28,7 +28,7 @@ cargo clippy --all-targets
## Conventions
-- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server.
+- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 bulk endpoints live under https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, not the main API reference.
- **The list evaluator and the Inbox Placement Tester have their own base URLs** on `ZBUrlProvider` (`list_evaluator_url`, `inbox_placement_url`). They are not built from `bulk_url`, and `send_evaluated_file` stays on the bulk base. Offline tests must point both at the fake server or they reach the live API.
- **The Inbox Placement Tester takes the API key as the `api_key` query parameter and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 344e752..9758019 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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, and filters.
+- Bulk email finder, bulk domain search, list evaluator, filters, and S3 bulk (`validate_s3_file`, `score_s3_file`, `export_s3_file`, `list_s3_files`).
- `ZBValidateOptions` accepts `domain_info`, `greylist_processing`, and `callback_url`. `callback_url` needs `greylist_processing: Some(true)` and is rejected without it, and when blank, before a request is sent.
- `ZBValidation` carries `domain_website_exists`, `domain_registrant_company_name`, and `message`. `message` is read from the API's `Message` key, returned while greylist processing is still running.
- Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `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`.
diff --git a/src/api/priority3.rs b/src/api/priority3.rs
index 7850df6..f48651e 100644
--- a/src/api/priority3.rs
+++ b/src/api/priority3.rs
@@ -4,6 +4,8 @@ use std::io::Read;
use reqwest::blocking::multipart::{Form, Part};
use serde_json::{from_str, Value};
+use serde::Deserialize;
+
use crate::utility::structures::bulk::ZBFileFeedback;
use crate::utility::{ZBError, ZBResult};
use crate::ZeroBounce;
@@ -13,6 +15,34 @@ fn bulk_api_root(bulk_url: &str) -> String {
trimmed.strip_suffix("/v2").unwrap_or(trimmed).to_string()
}
+fn post_bulk_root_form(
+ client: &ZeroBounce,
+ path: &str,
+ fields: HashMap,
+) -> ZBResult {
+ 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 body = client.read_api_response(response)?;
+ crate::utility::http_error::parse_json_body(&body)
+}
+
+#[derive(Deserialize, Debug)]
+pub struct S3ListFileEntry {
+ pub name: String,
+ pub size: i64,
+ pub last_modified: String,
+}
+
fn post_bulk_root_multipart(
client: &ZeroBounce,
url: String,
@@ -185,4 +215,63 @@ 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"));
+ }
+ 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()),
+ ]),
+ )
+ }
}
diff --git a/tests/offline_api.rs b/tests/offline_api.rs
index a4fe552..09c2d9f 100644
--- a/tests/offline_api.rs
+++ b/tests/offline_api.rs
@@ -74,6 +74,13 @@ fn success_body(path: &str, key: &str) -> (u16, &'static str, String) {
format!(r#"{{"ok":true,"echo_api_key":{quoted}}}"#),
);
}
+ if path.contains("listS3files") {
+ return (
+ 200,
+ "application/json",
+ format!(r#"[{{"name":{quoted},"size":1,"last_modified":""}}]"#),
+ );
+ }
if path.contains("validatebatch") {
return (
200,
@@ -751,6 +758,33 @@ fn files_scoring_and_priority3() {
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]
@@ -809,6 +843,11 @@ fn invalid_arguments_are_rejected_before_a_request() {
.unwrap_err()
.to_string()
.contains("domain_column"));
+ assert!(zb
+ .validate_s3_file(" ")
+ .unwrap_err()
+ .to_string()
+ .contains("file_name"));
assert_eq!(fake.len(), 0);
}
From d98fb367725571f73bf97a9fe3e3553a5a5da0c1 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Fri, 23 Oct 2026 23:58:31 +0400
Subject: [PATCH 09/17] feat: S3 email finder / domain search and SFTP/FTP bulk
endpoints
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ports b0444f8 from the Python SDK. These are documented but had never been in
this SDK:
- POST /emailFinderS3file and /domainSearchS3file, from
https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api
- POST /validateSFTPfile, /scoreSFTPfile, /emailFinderSFTPfile,
/domainSearchSFTPfile and /exportSFTPfile, from
https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api
All six file jobs share 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 S3 ones and are easy to get wrong:
- validate_sftp_file's file_name is optional, so it takes Option<&str>; None
uses the input bucket from the account's settings. Every other job requires
a name, and a blank one is still rejected.
- export_sftp_file takes export_path, not the export_bucket that
export_s3_file uses, and None uses the export path from those settings.
The existing S3 methods are untouched, so no signature changes for callers.
Tests: 132 pass. Offline only — the fake server's default success body already
covers these routes.
Co-Authored-By: Claude Opus 5
---
AGENTS.md | 2 +-
CHANGELOG.md | 3 +-
README.md | 31 ++++++++
documentation.md | 31 ++++++++
src/api/priority3.rs | 185 +++++++++++++++++++++++++++++++++++++++++++
tests/offline_api.rs | 131 ++++++++++++++++++++++++++++++
6 files changed, 381 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index cb74ebd..9dc681c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,7 @@ Guidance for AI coding agents working in this repository.
- `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/api/priority3.rs`: bulk email finder, domain search, list evaluator, filters, and the S3 / SFTP bulk jobs. Finder and domain search paths are on the bulk host without `/v2`. The six file jobs go through `bulk_root_file_job`; two shapes differ from the rest and are easy to get wrong — `validate_sftp_file`'s `file_name` is optional (hence `Option`), and `export_sftp_file` takes `export_path`, not `export_bucket`.
- `src/api/inbox_placement.rs`: Inbox Placement Tester.
- `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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9758019..616571d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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`).
+- Bulk email finder, bulk domain search, list evaluator, filters, and 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`, and `export_sftp_file`. The server, path and protocol come from your ZeroBounce settings. `validate_sftp_file` takes `file_name` as `Option` because the docs list it as optional there alone; `export_sftp_file` takes `export_path` rather than the `export_bucket` `export_s3_file` uses.
- `ZBValidateOptions` accepts `domain_info`, `greylist_processing`, and `callback_url`. `callback_url` needs `greylist_processing: Some(true)` and is rejected without it, and when blank, before a request is sent.
- `ZBValidation` carries `domain_website_exists`, `domain_registrant_company_name`, and `message`. `message` is read from the API's `Message` key, returned while greylist processing is still running.
- Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `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`.
diff --git a/README.md b/README.md
index 2c9df1e..1d52a2c 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,37 @@ let webhook = zb.get_inbox_placement_webhook()?;
zb.delete_inbox_placement_test("123")?;
```
+## 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.
+
+```rust
+let zb = ZeroBounce::new("");
+
+// Amazon S3. file_name is the full path, as returned by list_s3_files.
+zb.email_finder_s3_file("lists/leads.csv", true, None, None)?;
+zb.domain_search_s3_file(
+ "lists/leads.csv",
+ true,
+ Some(true), // remove_duplicates
+ Some("https://example.com/zerobounce-done"), // return_url
+)?;
+
+// SFTP/FTP. The server, path and protocol come from your ZeroBounce settings.
+// file_name is optional for validate_sftp_file alone — None uses the input
+// bucket from those settings.
+zb.validate_sftp_file(None, true, None, None)?;
+zb.score_sftp_file("my_file.txt", true, None, None)?;
+zb.email_finder_sftp_file("my_file.txt", true, None, None)?;
+zb.domain_search_sftp_file("my_file.txt", true, None, None)?;
+
+// Note the field: this one takes export_path, not export_bucket.
+// None uses the export path from your settings.
+zb.export_sftp_file("", Some("/exports"))?;
+```
+
## Per-API base URLs
The list evaluator and the Inbox Placement Tester have their own base URLs,
diff --git a/documentation.md b/documentation.md
index a6a954a..7be1cf4 100644
--- a/documentation.md
+++ b/documentation.md
@@ -117,6 +117,37 @@ let webhook = zb.get_inbox_placement_webhook()?;
zb.delete_inbox_placement_test("123")?;
```
+## 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.
+
+```rust
+let zb = ZeroBounce::new("");
+
+// Amazon S3. file_name is the full path, as returned by list_s3_files.
+zb.email_finder_s3_file("lists/leads.csv", true, None, None)?;
+zb.domain_search_s3_file(
+ "lists/leads.csv",
+ true,
+ Some(true), // remove_duplicates
+ Some("https://example.com/zerobounce-done"), // return_url
+)?;
+
+// SFTP/FTP. The server, path and protocol come from your ZeroBounce settings.
+// file_name is optional for validate_sftp_file alone — None uses the input
+// bucket from those settings.
+zb.validate_sftp_file(None, true, None, None)?;
+zb.score_sftp_file("my_file.txt", true, None, None)?;
+zb.email_finder_sftp_file("my_file.txt", true, None, None)?;
+zb.domain_search_sftp_file("my_file.txt", true, None, None)?;
+
+// Note the field: this one takes export_path, not export_bucket.
+// None uses the export path from your settings.
+zb.export_sftp_file("", Some("/exports"))?;
+```
+
## Per-API base URLs
The list evaluator and the Inbox Placement Tester have their own base URLs,
diff --git a/src/api/priority3.rs b/src/api/priority3.rs
index f48651e..ffb6597 100644
--- a/src/api/priority3.rs
+++ b/src/api/priority3.rs
@@ -36,6 +36,45 @@ fn post_bulk_root_form(
crate::utility::http_error::parse_json_body(&body)
}
+/// POST one of the documented S3 / SFTP file jobs on the bulkapi root.
+///
+/// `file_name` is optional only for `validateSFTPfile`; every other job requires it.
+fn bulk_root_file_job(
+ client: &ZeroBounce,
+ path: &str,
+ file_name: Option<&str>,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: Option<&str>,
+ file_name_required: bool,
+) -> ZBResult {
+ match file_name {
+ None if file_name_required => {
+ return Err(ZBError::explicit("empty parameter: file_name"));
+ }
+ Some(name) if name.trim().is_empty() => {
+ return Err(ZBError::explicit("empty parameter: file_name"));
+ }
+ _ => {}
+ }
+
+ let mut fields: HashMap = HashMap::new();
+ fields.insert("has_header_row".to_string(), has_header_row.to_string());
+ if let Some(name) = file_name {
+ fields.insert("file_name".to_string(), name.to_string());
+ }
+ if let Some(remove_duplicates) = remove_duplicates {
+ fields.insert(
+ "remove_duplicates".to_string(),
+ remove_duplicates.to_string(),
+ );
+ }
+ if let Some(return_url) = return_url {
+ fields.insert("return_url".to_string(), return_url.to_string());
+ }
+ post_bulk_root_form(client, path, fields)
+}
+
#[derive(Deserialize, Debug)]
pub struct S3ListFileEntry {
pub name: String,
@@ -274,4 +313,150 @@ impl ZeroBounce {
]),
)
}
+
+ /// Run the email finder on a file in your Amazon S3 bucket (POST /emailFinderS3file).
+ ///
+ /// `file_name` is the full path as returned by [`list_s3_files`](Self::list_s3_files).
+ pub fn email_finder_s3_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: Option<&str>,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "emailFinderS3file",
+ Some(file_name),
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ true,
+ )
+ }
+
+ /// Run domain search on a file in your Amazon S3 bucket (POST /domainSearchS3file).
+ pub fn domain_search_s3_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: Option<&str>,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "domainSearchS3file",
+ Some(file_name),
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ true,
+ )
+ }
+
+ // SFTP / FTP bulk. The server, path and protocol come from your ZeroBounce settings.
+
+ /// Validate a file from your SFTP/FTP server (POST /validateSFTPfile).
+ ///
+ /// The docs list `file_name` as optional here alone; pass `None` to use the input
+ /// bucket configured in your ZeroBounce settings.
+ pub fn validate_sftp_file(
+ &self,
+ file_name: Option<&str>,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: Option<&str>,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "validateSFTPfile",
+ file_name,
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ false,
+ )
+ }
+
+ /// Score a file from your SFTP/FTP server (POST /scoreSFTPfile).
+ ///
+ /// `file_name` is a plain name such as `my_file.txt`.
+ pub fn score_sftp_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: Option<&str>,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "scoreSFTPfile",
+ Some(file_name),
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ true,
+ )
+ }
+
+ /// Run the email finder on a file from your SFTP/FTP server (POST /emailFinderSFTPfile).
+ pub fn email_finder_sftp_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: Option<&str>,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "emailFinderSFTPfile",
+ Some(file_name),
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ true,
+ )
+ }
+
+ /// Run domain search on a file from your SFTP/FTP server (POST /domainSearchSFTPfile).
+ pub fn domain_search_sftp_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: Option<&str>,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "domainSearchSFTPfile",
+ Some(file_name),
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ true,
+ )
+ }
+
+ /// Export a validated or scored file back to your SFTP/FTP server (POST /exportSFTPfile).
+ ///
+ /// Note the field name: this endpoint takes `export_path`, not the `export_bucket`
+ /// that [`export_s3_file`](Self::export_s3_file) uses. Pass `None` to use the export
+ /// path from your ZeroBounce settings.
+ pub fn export_sftp_file(
+ &self,
+ file_id: &str,
+ export_path: Option<&str>,
+ ) -> ZBResult {
+ if file_id.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: file_id"));
+ }
+ let mut fields = HashMap::from([("file_id".to_string(), file_id.to_string())]);
+ if let Some(export_path) = export_path {
+ if export_path.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: export_path"));
+ }
+ fields.insert("export_path".to_string(), export_path.to_string());
+ }
+ post_bulk_root_form(self, "exportSFTPfile", fields)
+ }
}
diff --git a/tests/offline_api.rs b/tests/offline_api.rs
index 09c2d9f..dff33b1 100644
--- a/tests/offline_api.rs
+++ b/tests/offline_api.rs
@@ -787,6 +787,137 @@ fn files_scoring_and_priority3() {
);
}
+#[test]
+fn s3_and_sftp_file_jobs_send_the_documented_fields_only() {
+ let (port, fake) = start_fake();
+ let zb = client(port, "user");
+
+ zb.email_finder_s3_file("lists/leads.csv", true, None, None)
+ .unwrap();
+ let request = only(&fake);
+ assert_eq!(request.method, "POST");
+ assert!(
+ request.path.ends_with("/emailFinderS3file"),
+ "{}",
+ request.path
+ );
+ assert_eq!(
+ request.fields.get("file_name").map(String::as_str),
+ Some("lists/leads.csv")
+ );
+ // Booleans go on the wire as the strings "true"/"false".
+ assert_eq!(
+ request.fields.get("has_header_row").map(String::as_str),
+ Some("true")
+ );
+ // Unset options stay off the request entirely.
+ assert!(!request.fields.contains_key("remove_duplicates"));
+ assert!(!request.fields.contains_key("return_url"));
+
+ zb.domain_search_s3_file(
+ "lists/leads.csv",
+ false,
+ Some(true),
+ Some("https://example.com/done"),
+ )
+ .unwrap();
+ let request = only(&fake);
+ assert!(request.path.ends_with("/domainSearchS3file"));
+ assert_eq!(
+ request.fields.get("has_header_row").map(String::as_str),
+ Some("false")
+ );
+ assert_eq!(
+ request.fields.get("remove_duplicates").map(String::as_str),
+ Some("true")
+ );
+ assert_eq!(
+ request.fields.get("return_url").map(String::as_str),
+ Some("https://example.com/done")
+ );
+
+ // file_name is optional for validateSFTPfile alone: without it the input bucket
+ // from the account's settings is used.
+ zb.validate_sftp_file(None, true, None, None).unwrap();
+ let request = only(&fake);
+ assert!(
+ request.path.ends_with("/validateSFTPfile"),
+ "{}",
+ request.path
+ );
+ assert!(!request.fields.contains_key("file_name"));
+ assert_eq!(
+ request.fields.get("has_header_row").map(String::as_str),
+ Some("true")
+ );
+
+ zb.validate_sftp_file(Some("my_file.txt"), true, None, None)
+ .unwrap();
+ assert_eq!(
+ only(&fake).fields.get("file_name").map(String::as_str),
+ Some("my_file.txt")
+ );
+
+ zb.score_sftp_file("my_file.txt", true, None, None).unwrap();
+ assert!(only(&fake).path.ends_with("/scoreSFTPfile"));
+ zb.email_finder_sftp_file("my_file.txt", true, None, None)
+ .unwrap();
+ assert!(only(&fake).path.ends_with("/emailFinderSFTPfile"));
+ zb.domain_search_sftp_file("my_file.txt", true, None, None)
+ .unwrap();
+ assert!(only(&fake).path.ends_with("/domainSearchSFTPfile"));
+
+ // exportSFTPfile takes export_path, not the export_bucket exportS3file uses.
+ zb.export_sftp_file("fid", Some("/exports")).unwrap();
+ let request = only(&fake);
+ assert!(
+ request.path.ends_with("/exportSFTPfile"),
+ "{}",
+ request.path
+ );
+ assert_eq!(
+ request.fields.get("export_path").map(String::as_str),
+ Some("/exports")
+ );
+ assert!(!request.fields.contains_key("export_bucket"));
+
+ zb.export_sftp_file("fid", None).unwrap();
+ assert!(!only(&fake).fields.contains_key("export_path"));
+}
+
+#[test]
+fn s3_and_sftp_file_jobs_reject_invalid_arguments_before_a_request() {
+ let (port, fake) = start_fake();
+ let zb = client(port, "user");
+
+ for error in [
+ zb.email_finder_s3_file(" ", true, None, None).unwrap_err(),
+ zb.domain_search_s3_file("", true, None, None).unwrap_err(),
+ zb.score_sftp_file(" ", true, None, None).unwrap_err(),
+ zb.email_finder_sftp_file("", true, None, None).unwrap_err(),
+ zb.domain_search_sftp_file("", true, None, None)
+ .unwrap_err(),
+ // None is allowed here, but a blank name still is not.
+ zb.validate_sftp_file(Some(" "), true, None, None)
+ .unwrap_err(),
+ ] {
+ assert!(error.to_string().contains("file_name"), "{error}");
+ }
+
+ assert!(zb
+ .export_sftp_file(" ", None)
+ .unwrap_err()
+ .to_string()
+ .contains("file_id"));
+ assert!(zb
+ .export_sftp_file("fid", Some(" "))
+ .unwrap_err()
+ .to_string()
+ .contains("export_path"));
+
+ assert_eq!(fake.len(), 0);
+}
+
#[test]
fn invalid_arguments_are_rejected_before_a_request() {
let (port, fake) = start_fake();
From 24d50fd7542878ebf59cd57696fe42ad3b9aa6c5 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Tue, 20 Oct 2026 22:30:39 +0400
Subject: [PATCH 10/17] feat: add ai_agent_mailbox sub_status (RD-66)
The API now returns ai_agent_mailbox under both the valid and catch-all
statuses. No new status was added.
Adds the AiAgentMailbox variant and its from_str arm following
RoleBasedCatchAll, plus SS_AI_AGENT_MAILBOX alongside the other sub_status
string constants.
Unknown values were already handled best-in-class here: sub_status is kept as
a String and sub_status_enum() falls back to UnknownValue(String), so an
unrecognised value is preserved rather than dropped. A test pins that
round-trip alongside the two new ones.
Co-Authored-By: Claude Opus 5
---
src/utility/mod.rs | 1 +
src/utility/structures/validate_enums.rs | 2 +
tests/validation/simple_validation.rs | 50 ++++++++++++++++++++++++
3 files changed, 53 insertions(+)
diff --git a/src/utility/mod.rs b/src/utility/mod.rs
index 03c5cfc..0e5997b 100644
--- a/src/utility/mod.rs
+++ b/src/utility/mod.rs
@@ -120,6 +120,7 @@ pub const SS_ROLE_BASED_CATCH_ALL: &str = "role_based_catch_all";
pub const SS_DISPOSABLE: &str = "disposable";
pub const SS_TOXIC: &str = "toxic";
pub const SS_ACCEPT_ALL: &str = "accept_all";
+pub const SS_AI_AGENT_MAILBOX: &str = "ai_agent_mailbox";
#[derive(Debug)]
pub enum ZBError {
diff --git a/src/utility/structures/validate_enums.rs b/src/utility/structures/validate_enums.rs
index 1c6c061..4f0003a 100644
--- a/src/utility/structures/validate_enums.rs
+++ b/src/utility/structures/validate_enums.rs
@@ -72,6 +72,7 @@ pub enum ZBValidateSubStatus {
AcceptAll,
RoleBasedAcceptAll,
Gold,
+ AiAgentMailbox,
/// Unknown or future API value (backward compatible)
UnknownValue(String),
}
@@ -112,6 +113,7 @@ impl FromStr for ZBValidateSubStatus {
"accept_all" => ZBValidateSubStatus::AcceptAll,
"role_based_accept_all" => ZBValidateSubStatus::RoleBasedAcceptAll,
"gold" => ZBValidateSubStatus::Gold,
+ "ai_agent_mailbox" => ZBValidateSubStatus::AiAgentMailbox,
other => ZBValidateSubStatus::UnknownValue(other.to_string()),
})
}
diff --git a/tests/validation/simple_validation.rs b/tests/validation/simple_validation.rs
index 18f62bf..fd3208a 100644
--- a/tests/validation/simple_validation.rs
+++ b/tests/validation/simple_validation.rs
@@ -1,4 +1,5 @@
use zero_bounce::utility::{ENDPOINT_VALIDATE, CONTENT_TYPE_JSON, ZBError, mock_constants};
+use zero_bounce::utility::structures::validate_enums::ZBValidateSubStatus;
use crate::common::{EMAIL, SANDBOX_IP};
use crate::common::{instantiate, invalid_url_zb_instance, endpoint_matcher};
@@ -90,3 +91,52 @@ fn test_simple_validation_with_ip_ok() {
assert!(validation_response.is_ok());
mock.assert();
}
+
+
+/// Runs one validate call against a body carrying the given status/sub_status
+/// and hands back the parsed sub-status enum.
+#[cfg(test)]
+fn sub_status_for(status: &str, sub_status: &str) -> ZBValidateSubStatus {
+ let (mut mock_server, zb_instance) = instantiate();
+ // Start from the full valid-response fixture so every required field is
+ // present, then swap only the two values under test.
+ let body = mock_constants::VALIDATION_RESPONSE_VALID
+ .replace(r#""status": "valid""#, &format!(r#""status": "{}""#, status))
+ .replace(r#""sub_status": """#, &format!(r#""sub_status": "{}""#, sub_status));
+
+ let mock = mock_server.mock("GET", endpoint_matcher(ENDPOINT_VALIDATE))
+ .with_status(200)
+ .with_header("content-type", CONTENT_TYPE_JSON)
+ .with_body(body)
+ .create();
+
+ let validation = zb_instance.validate_email(EMAIL);
+ mock.assert();
+ validation.expect("validation should succeed").sub_status_enum()
+}
+
+#[test]
+fn test_sub_status_ai_agent_mailbox_valid() {
+ assert_eq!(
+ sub_status_for("valid", "ai_agent_mailbox"),
+ ZBValidateSubStatus::AiAgentMailbox
+ );
+}
+
+#[test]
+fn test_sub_status_ai_agent_mailbox_catch_all() {
+ assert_eq!(
+ sub_status_for("catch-all", "ai_agent_mailbox"),
+ ZBValidateSubStatus::AiAgentMailbox
+ );
+}
+
+#[test]
+fn test_unknown_sub_status_is_preserved() {
+ // from_str has a catch-all arm, so a sub_status added after this SDK
+ // shipped round-trips through UnknownValue instead of being lost.
+ assert_eq!(
+ sub_status_for("valid", "some_future_sub_status"),
+ ZBValidateSubStatus::UnknownValue("some_future_sub_status".to_string())
+ );
+}
From a5a6e66c30a13a38d63192c8009d17783d0c323c Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sat, 24 Oct 2026 05:34:37 +0400
Subject: [PATCH 11/17] fix: match what the live API actually accepts
Verified against the live API; where the API and the docs disagree, the
API wins and the call site says so.
- validatebatch takes timeout as a JSON string. Sent as a number the API
rejects the whole batch with the account-level "Invalid API Key or
your account ran out of credits".
- The Inbox Placement Tester authenticates with a Bearer header, not an
api_key parameter.
- send_evaluated_file takes the list evaluator's file_id as a form
field, not a file upload. Its signature changes accordingly.
- The S3 and SFTP jobs require file_name and return_url, validateSFTPfile
included, although the docs list file_name as optional for that one.
{"success": false} on an HTTP 200 is a failure as well: the API answers
an unknown file id that way, and two tests had pinned the wrong
behaviour. ZBError::ApiError carries the HTTP status, reachable through
ZBError::status_code(), so callers can tell a 403 from Cloudflare apart
from a 400/404 from the API.
Co-Authored-By: Claude Opus 5
---
src/api/inbox_placement.rs | 8 +-
src/api/priority3.rs | 101 +++++++++++---------
src/api/validation.rs | 5 +-
src/lib.rs | 2 +-
src/utility/http_error.rs | 31 +++++-
src/utility/mod.rs | 20 ++++
tests/bulk/ai_scoring.rs | 15 +--
tests/bulk/validation.rs | 15 +--
tests/generic_endpoints/activity_data.rs | 2 +-
tests/generic_endpoints/api_usage.rs | 2 +-
tests/generic_endpoints/domain_search_v2.rs | 2 +-
tests/generic_endpoints/find_email_v2.rs | 2 +-
tests/generic_endpoints/find_mail.rs | 4 +-
tests/generic_endpoints/get_credits.rs | 2 +-
tests/offline_api.rs | 88 +++++++++++------
tests/validation/batch_validation.rs | 2 +-
tests/validation/simple_validation.rs | 2 +-
17 files changed, 197 insertions(+), 106 deletions(-)
diff --git a/src/api/inbox_placement.rs b/src/api/inbox_placement.rs
index 48da843..7c69ebb 100644
--- a/src/api/inbox_placement.rs
+++ b/src/api/inbox_placement.rs
@@ -235,11 +235,10 @@ impl ZeroBounce {
&'a self,
method: &str,
path: &str,
- mut query: HashMap<&'a str, &'a str>,
+ query: HashMap<&'a str, &'a str>,
body: Option,
) -> ZBResult {
self.require_key()?;
- query.insert("api_key", self.api_key.as_str());
let url = self.url_provider.inbox_placement_url_of(path);
let parsed = reqwest::Url::parse(&url)
@@ -251,7 +250,10 @@ impl ZeroBounce {
"DELETE" => self.client.delete(parsed),
_ => self.client.get(parsed),
}
- .query(&query);
+ .query(&query)
+ // LIVE: the Inbox Placement Tester authenticates with a Bearer header.
+ // Sent as an api_key parameter the request is rejected as unauthorized.
+ .bearer_auth(self.api_key.as_str());
if let Some(body) = body {
// reqwest's json() helper needs its "json" feature, which this crate
diff --git a/src/api/priority3.rs b/src/api/priority3.rs
index ffb6597..f28acf1 100644
--- a/src/api/priority3.rs
+++ b/src/api/priority3.rs
@@ -31,47 +31,45 @@ fn post_bulk_root_form(
.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 response = client
+ .client
+ .post(crate::parse_request_url(&url)?)
+ .form(&form)
+ .send()?;
let body = client.read_api_response(response)?;
crate::utility::http_error::parse_json_body(&body)
}
/// POST one of the documented S3 / SFTP file jobs on the bulkapi root.
///
-/// `file_name` is optional only for `validateSFTPfile`; every other job requires it.
+/// LIVE: `file_name` and `return_url` are required on every one of these,
+/// including `validateSFTPfile`, which the docs list `file_name` as optional
+/// for. Without either the API answers "This field is required".
fn bulk_root_file_job(
client: &ZeroBounce,
path: &str,
- file_name: Option<&str>,
+ file_name: &str,
has_header_row: bool,
remove_duplicates: Option,
- return_url: Option<&str>,
- file_name_required: bool,
+ return_url: &str,
) -> ZBResult {
- match file_name {
- None if file_name_required => {
- return Err(ZBError::explicit("empty parameter: file_name"));
- }
- Some(name) if name.trim().is_empty() => {
- return Err(ZBError::explicit("empty parameter: file_name"));
- }
- _ => {}
+ if file_name.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: file_name"));
+ }
+ if return_url.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: return_url"));
}
let mut fields: HashMap = HashMap::new();
fields.insert("has_header_row".to_string(), has_header_row.to_string());
- if let Some(name) = file_name {
- fields.insert("file_name".to_string(), name.to_string());
- }
+ fields.insert("file_name".to_string(), file_name.to_string());
+ fields.insert("return_url".to_string(), return_url.to_string());
if let Some(remove_duplicates) = remove_duplicates {
fields.insert(
"remove_duplicates".to_string(),
remove_duplicates.to_string(),
);
}
- if let Some(return_url) = return_url {
- fields.insert("return_url".to_string(), return_url.to_string());
- }
post_bulk_root_form(client, path, fields)
}
@@ -148,18 +146,33 @@ impl ZeroBounce {
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)?;
+ /// Submit a finished evaluation for validation. `POST /v2/send-evaluated-file/`
+ ///
+ /// LIVE: this takes the list evaluator's `file_id` as a form field, not the
+ /// file upload the docs describe — an upload is rejected with
+ /// "file_id: This field is required". It returns a new validation job's
+ /// `file_id`, which answers "File cannot be found" for a short while after
+ /// it is accepted.
+ pub fn send_evaluated_file(&self, file_id: &str) -> ZBResult {
+ if file_id.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: file_id"));
+ }
+ self.require_key()?;
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())
+ let form = HashMap::from([
+ ("api_key", self.api_key.clone()),
+ ("file_id", file_id.to_string()),
+ ]);
+ let response = self
+ .client
+ .post(crate::parse_request_url(&url)?)
+ .form(&form)
+ .send()?;
+ let body = self.read_api_response(response)?;
+ crate::utility::http_error::parse_json_body(&body)
}
pub fn send_domain_search_file(
@@ -322,16 +335,15 @@ impl ZeroBounce {
file_name: &str,
has_header_row: bool,
remove_duplicates: Option,
- return_url: Option<&str>,
+ return_url: &str,
) -> ZBResult {
bulk_root_file_job(
self,
"emailFinderS3file",
- Some(file_name),
+ file_name,
has_header_row,
remove_duplicates,
return_url,
- true,
)
}
@@ -341,16 +353,15 @@ impl ZeroBounce {
file_name: &str,
has_header_row: bool,
remove_duplicates: Option,
- return_url: Option<&str>,
+ return_url: &str,
) -> ZBResult {
bulk_root_file_job(
self,
"domainSearchS3file",
- Some(file_name),
+ file_name,
has_header_row,
remove_duplicates,
return_url,
- true,
)
}
@@ -358,14 +369,14 @@ impl ZeroBounce {
/// Validate a file from your SFTP/FTP server (POST /validateSFTPfile).
///
- /// The docs list `file_name` as optional here alone; pass `None` to use the input
- /// bucket configured in your ZeroBounce settings.
+ /// LIVE: `file_name` is required here too, although the docs list it as
+ /// optional for this one endpoint.
pub fn validate_sftp_file(
&self,
- file_name: Option<&str>,
+ file_name: &str,
has_header_row: bool,
remove_duplicates: Option,
- return_url: Option<&str>,
+ return_url: &str,
) -> ZBResult {
bulk_root_file_job(
self,
@@ -374,7 +385,6 @@ impl ZeroBounce {
has_header_row,
remove_duplicates,
return_url,
- false,
)
}
@@ -386,16 +396,15 @@ impl ZeroBounce {
file_name: &str,
has_header_row: bool,
remove_duplicates: Option,
- return_url: Option<&str>,
+ return_url: &str,
) -> ZBResult {
bulk_root_file_job(
self,
"scoreSFTPfile",
- Some(file_name),
+ file_name,
has_header_row,
remove_duplicates,
return_url,
- true,
)
}
@@ -405,16 +414,15 @@ impl ZeroBounce {
file_name: &str,
has_header_row: bool,
remove_duplicates: Option,
- return_url: Option<&str>,
+ return_url: &str,
) -> ZBResult {
bulk_root_file_job(
self,
"emailFinderSFTPfile",
- Some(file_name),
+ file_name,
has_header_row,
remove_duplicates,
return_url,
- true,
)
}
@@ -424,16 +432,15 @@ impl ZeroBounce {
file_name: &str,
has_header_row: bool,
remove_duplicates: Option,
- return_url: Option<&str>,
+ return_url: &str,
) -> ZBResult {
bulk_root_file_job(
self,
"domainSearchSFTPfile",
- Some(file_name),
+ file_name,
has_header_row,
remove_duplicates,
return_url,
- true,
)
}
diff --git a/src/api/validation.rs b/src/api/validation.rs
index 635d9a1..2b3d3b6 100644
--- a/src/api/validation.rs
+++ b/src/api/validation.rs
@@ -155,7 +155,10 @@ impl ZeroBounce {
("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()));
+ // 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".
+ request_body_map.insert("timeout".to_string(), Value::String(timeout.to_string()));
}
if let Some(activity_data) = options.activity_data {
request_body_map.insert("activity_data".to_string(), Value::Bool(activity_data));
diff --git a/src/lib.rs b/src/lib.rs
index 395d879..1ad5de1 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -177,7 +177,7 @@ impl ZeroBounce {
}
}
-fn parse_request_url(url: &str) -> ZBResult {
+pub(crate) 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);
diff --git a/src/utility/http_error.rs b/src/utility/http_error.rs
index 54c6436..d4334d2 100644
--- a/src/utility/http_error.rs
+++ b/src/utility/http_error.rs
@@ -121,6 +121,16 @@ pub fn message_from_body(status: u16, content_type: &str, body: &str) -> String
message
}
+/// Whether a "success" field means failure. The API sends it as a JSON false,
+/// "false" or "False" depending on the endpoint.
+fn is_falsy_api_success(value: &serde_json::Value) -> bool {
+ match value {
+ serde_json::Value::Bool(flag) => !flag,
+ serde_json::Value::String(text) => text.eq_ignore_ascii_case("false"),
+ _ => false,
+ }
+}
+
/// 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);
@@ -131,11 +141,24 @@ pub fn ensure_json_api_body(status: u16, content_type: &str, body: &str) -> ZBRe
|| content_type_is_html(content_type)
|| (!trimmed.starts_with('{') && !trimmed.starts_with('['))
{
- return Err(ZBError::ExplicitError(message_from_body(
+ return Err(ZBError::api(
status,
- content_type,
- body,
- )));
+ message_from_body(status, content_type, body),
+ ));
+ }
+ // LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The
+ // API answers an unknown bulk file id that way, with a 200.
+ if trimmed.starts_with('{') {
+ if let Ok(serde_json::Value::Object(object)) =
+ serde_json::from_str::(trimmed)
+ {
+ if object.get("success").is_some_and(is_falsy_api_success) {
+ return Err(ZBError::api(
+ status,
+ message_from_body(status, content_type, body),
+ ));
+ }
+ }
}
Ok(())
}
diff --git a/src/utility/mod.rs b/src/utility/mod.rs
index 0e5997b..5ecc190 100644
--- a/src/utility/mod.rs
+++ b/src/utility/mod.rs
@@ -125,6 +125,10 @@ pub const SS_AI_AGENT_MAILBOX: &str = "ai_agent_mailbox";
#[derive(Debug)]
pub enum ZBError {
ExplicitError(String),
+ /// A failed API response. The status lets callers tell a 403 from
+ /// Cloudflare apart from a 400/404 from the API and a 429 from the Warmup
+ /// rate limit. Both the validation client and the Warmup client return it.
+ ApiError { status: u16, message: String },
JsonError(serde_json::Error),
IntParseError(std::num::ParseIntError),
RequestError(reqwest::Error),
@@ -137,12 +141,28 @@ impl ZBError {
pub fn explicit(string: &str) -> ZBError {
ZBError::ExplicitError(string.to_string())
}
+
+ pub fn api(status: u16, message: impl Into) -> ZBError {
+ ZBError::ApiError {
+ status,
+ message: message.into(),
+ }
+ }
+
+ /// The HTTP status of a failed API response, when there was one.
+ pub fn status_code(&self) -> Option {
+ match self {
+ ZBError::ApiError { status, .. } => Some(*status),
+ _ => None,
+ }
+ }
}
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::ApiError { message, .. } => write!(formatter, "{message}"),
ZBError::JsonError(_) => {
write!(
formatter,
diff --git a/tests/bulk/ai_scoring.rs b/tests/bulk/ai_scoring.rs
index 614f0ab..694f7dd 100644
--- a/tests/bulk/ai_scoring.rs
+++ b/tests/bulk/ai_scoring.rs
@@ -44,7 +44,7 @@ fn test_ai_scoring_submit_bad_request() {
assert!(response.is_err());
let zb_error = response.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
@@ -99,7 +99,7 @@ fn test_ai_scoring_status_bad_request() {
assert!(response.is_err());
let zb_error = response.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
@@ -237,7 +237,7 @@ fn test_ai_scoring_delete_bad_request() {
assert!(response.is_err());
let zb_error = response.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
@@ -252,12 +252,15 @@ fn test_ai_scoring_delete_not_ok() {
.with_body(mock_constants::BULK_VALIDATION_DELETE_NOT_FOUND)
.create();
+ // LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The
+ // API answers an unknown file id that way, with a 200.
let response = zb_instance.ai_scoring_result_delete("mock_file_id");
mock.assert();
- assert!(response.is_ok());
- let response_obj = response.unwrap();
- assert_eq!(response_obj.success, false, "{:#?}", response_obj);
+ let zb_error = response.unwrap_err();
+ assert_eq!(zb_error.status_code(), Some(200));
+ let message = zb_error.to_string();
+ assert!(message.len() < 500, "{message}");
}
#[test]
diff --git a/tests/bulk/validation.rs b/tests/bulk/validation.rs
index 359d62f..ab49aeb 100644
--- a/tests/bulk/validation.rs
+++ b/tests/bulk/validation.rs
@@ -44,7 +44,7 @@ fn test_bulk_validate_submit_bad_request() {
assert!(response.is_err());
let zb_error = response.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
@@ -99,7 +99,7 @@ fn test_bulk_validate_status_bad_request() {
assert!(response.is_err());
let zb_error = response.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
@@ -267,7 +267,7 @@ fn test_bulk_validate_delete_bad_request() {
assert!(response.is_err());
let zb_error = response.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
@@ -282,12 +282,15 @@ fn test_bulk_validate_delete_not_ok() {
.with_body(mock_constants::BULK_VALIDATION_DELETE_NOT_FOUND)
.create();
+ // LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The
+ // API answers an unknown file id that way, with a 200.
let response = zb_instance.bulk_validation_result_delete("mock_file_id");
mock.assert();
- assert!(response.is_ok());
- let response_obj = response.unwrap();
- assert_eq!(response_obj.success, false, "{:#?}", response_obj);
+ let zb_error = response.unwrap_err();
+ assert_eq!(zb_error.status_code(), Some(200));
+ let message = zb_error.to_string();
+ assert!(message.len() < 500, "{message}");
}
#[test]
diff --git a/tests/generic_endpoints/activity_data.rs b/tests/generic_endpoints/activity_data.rs
index 57b6cec..8a44611 100644
--- a/tests/generic_endpoints/activity_data.rs
+++ b/tests/generic_endpoints/activity_data.rs
@@ -50,7 +50,7 @@ fn test_activity_data_bad_request() {
mock.assert();
let zb_error = activity_data.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
diff --git a/tests/generic_endpoints/api_usage.rs b/tests/generic_endpoints/api_usage.rs
index 6ecd4e2..ff92234 100644
--- a/tests/generic_endpoints/api_usage.rs
+++ b/tests/generic_endpoints/api_usage.rs
@@ -51,7 +51,7 @@ fn test_api_usage_bad_request() {
mock.assert();
let zb_error = api_usage.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
diff --git a/tests/generic_endpoints/domain_search_v2.rs b/tests/generic_endpoints/domain_search_v2.rs
index 79af7ea..048f4f2 100644
--- a/tests/generic_endpoints/domain_search_v2.rs
+++ b/tests/generic_endpoints/domain_search_v2.rs
@@ -120,7 +120,7 @@ fn test_domain_search_v2_bad_request() {
mock.assert();
let zb_error = result.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
diff --git a/tests/generic_endpoints/find_email_v2.rs b/tests/generic_endpoints/find_email_v2.rs
index 0f648ea..d485b25 100644
--- a/tests/generic_endpoints/find_email_v2.rs
+++ b/tests/generic_endpoints/find_email_v2.rs
@@ -149,7 +149,7 @@ fn test_find_email_v2_bad_request() {
mock.assert();
let zb_error = result.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
diff --git a/tests/generic_endpoints/find_mail.rs b/tests/generic_endpoints/find_mail.rs
index 0c79a5b..e7b5010 100644
--- a/tests/generic_endpoints/find_mail.rs
+++ b/tests/generic_endpoints/find_mail.rs
@@ -56,7 +56,7 @@ fn test_find_bad_request() {
mock.assert();
let zb_error = find_mail_res.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
@@ -158,7 +158,7 @@ fn test_domain_search_bad_request() {
mock.assert();
let zb_error = domain_search_res.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
diff --git a/tests/generic_endpoints/get_credits.rs b/tests/generic_endpoints/get_credits.rs
index 19abb5a..c5e45eb 100644
--- a/tests/generic_endpoints/get_credits.rs
+++ b/tests/generic_endpoints/get_credits.rs
@@ -49,7 +49,7 @@ fn test_credits_bad_request() {
mock.assert();
let credits_error = credits.unwrap_err();
- let ZBError::ExplicitError(_) = credits_error else {
+ let ZBError::ApiError { .. } = credits_error else {
panic!("unexpected error: {:#?}", credits_error);
};
}
diff --git a/tests/offline_api.rs b/tests/offline_api.rs
index dff33b1..65a10df 100644
--- a/tests/offline_api.rs
+++ b/tests/offline_api.rs
@@ -21,6 +21,7 @@ struct Recorded {
method: String,
path: String,
api_key: String,
+ authorization: String,
fields: HashMap,
}
@@ -245,11 +246,15 @@ fn record_request(head: &str, body: &[u8]) -> Recorded {
let mut fields = HashMap::new();
parse_query(query, &mut fields);
let mut content_type = String::new();
+ let mut authorization = String::new();
for line in lines {
let lower = line.to_ascii_lowercase();
if let Some(value) = lower.strip_prefix("content-type:") {
content_type = value.trim().to_string();
}
+ if lower.starts_with("authorization:") {
+ authorization = line["authorization:".len()..].trim().to_string();
+ }
}
let body_text = String::from_utf8_lossy(body);
if content_type.contains("application/json") || body_text.trim_start().starts_with('{') {
@@ -311,11 +316,22 @@ fn record_request(head: &str, body: &[u8]) -> Recorded {
}
}
}
- let api_key = fields.get("api_key").cloned().unwrap_or_default();
+ // The Inbox Placement Tester authenticates with a Bearer header rather than
+ // an api_key parameter, so the key can arrive either way.
+ let api_key = fields
+ .get("api_key")
+ .cloned()
+ .or_else(|| {
+ authorization
+ .strip_prefix("Bearer ")
+ .map(|key| key.trim().to_string())
+ })
+ .unwrap_or_default();
Recorded {
method,
path: path.to_string(),
api_key,
+ authorization,
fields,
}
}
@@ -735,9 +751,16 @@ fn files_scoring_and_priority3() {
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/"));
+ // LIVE: send-evaluated-file takes the evaluator's file_id as a form field,
+ // not a file upload.
+ zb.send_evaluated_file("eval-file-1").unwrap();
+ let request = only(&fake);
+ assert!(request.path.contains("/send-evaluated-file/"));
+ assert_eq!(request.method, "POST");
+ assert_eq!(
+ request.fields.get("file_id").map(String::as_str),
+ Some("eval-file-1")
+ );
zb.add_filter("exclude", "domain", "example.com").unwrap();
let request = only(&fake);
@@ -792,7 +815,7 @@ fn s3_and_sftp_file_jobs_send_the_documented_fields_only() {
let (port, fake) = start_fake();
let zb = client(port, "user");
- zb.email_finder_s3_file("lists/leads.csv", true, None, None)
+ zb.email_finder_s3_file("lists/leads.csv", true, None, "https://example.com/done")
.unwrap();
let request = only(&fake);
assert_eq!(request.method, "POST");
@@ -812,13 +835,12 @@ fn s3_and_sftp_file_jobs_send_the_documented_fields_only() {
);
// Unset options stay off the request entirely.
assert!(!request.fields.contains_key("remove_duplicates"));
- assert!(!request.fields.contains_key("return_url"));
zb.domain_search_s3_file(
"lists/leads.csv",
false,
Some(true),
- Some("https://example.com/done"),
+ "https://example.com/done",
)
.unwrap();
let request = only(&fake);
@@ -836,34 +858,32 @@ fn s3_and_sftp_file_jobs_send_the_documented_fields_only() {
Some("https://example.com/done")
);
- // file_name is optional for validateSFTPfile alone: without it the input bucket
- // from the account's settings is used.
- zb.validate_sftp_file(None, true, None, None).unwrap();
+ // LIVE: file_name is required here too, although the docs list it as
+ // optional for this one endpoint.
+ zb.validate_sftp_file("my_file.txt", true, None, "https://example.com/done")
+ .unwrap();
let request = only(&fake);
assert!(
request.path.ends_with("/validateSFTPfile"),
"{}",
request.path
);
- assert!(!request.fields.contains_key("file_name"));
+ assert_eq!(
+ request.fields.get("file_name").map(String::as_str),
+ Some("my_file.txt")
+ );
assert_eq!(
request.fields.get("has_header_row").map(String::as_str),
Some("true")
);
- zb.validate_sftp_file(Some("my_file.txt"), true, None, None)
+ zb.score_sftp_file("my_file.txt", true, None, "https://example.com/done")
.unwrap();
- assert_eq!(
- only(&fake).fields.get("file_name").map(String::as_str),
- Some("my_file.txt")
- );
-
- zb.score_sftp_file("my_file.txt", true, None, None).unwrap();
assert!(only(&fake).path.ends_with("/scoreSFTPfile"));
- zb.email_finder_sftp_file("my_file.txt", true, None, None)
+ zb.email_finder_sftp_file("my_file.txt", true, None, "https://example.com/done")
.unwrap();
assert!(only(&fake).path.ends_with("/emailFinderSFTPfile"));
- zb.domain_search_sftp_file("my_file.txt", true, None, None)
+ zb.domain_search_sftp_file("my_file.txt", true, None, "https://example.com/done")
.unwrap();
assert!(only(&fake).path.ends_with("/domainSearchSFTPfile"));
@@ -890,20 +910,30 @@ fn s3_and_sftp_file_jobs_reject_invalid_arguments_before_a_request() {
let (port, fake) = start_fake();
let zb = client(port, "user");
+ let done = "https://example.com/done";
for error in [
- zb.email_finder_s3_file(" ", true, None, None).unwrap_err(),
- zb.domain_search_s3_file("", true, None, None).unwrap_err(),
- zb.score_sftp_file(" ", true, None, None).unwrap_err(),
- zb.email_finder_sftp_file("", true, None, None).unwrap_err(),
- zb.domain_search_sftp_file("", true, None, None)
- .unwrap_err(),
- // None is allowed here, but a blank name still is not.
- zb.validate_sftp_file(Some(" "), true, None, None)
- .unwrap_err(),
+ zb.email_finder_s3_file(" ", true, None, done).unwrap_err(),
+ zb.domain_search_s3_file("", true, None, done).unwrap_err(),
+ zb.score_sftp_file(" ", true, None, done).unwrap_err(),
+ zb.email_finder_sftp_file("", true, None, done).unwrap_err(),
+ zb.domain_search_sftp_file("", true, None, done).unwrap_err(),
+ zb.validate_sftp_file(" ", true, None, done).unwrap_err(),
] {
assert!(error.to_string().contains("file_name"), "{error}");
}
+ // LIVE: return_url is required on every one of these jobs.
+ for error in [
+ zb.email_finder_s3_file("f.csv", true, None, "").unwrap_err(),
+ zb.domain_search_s3_file("f.csv", true, None, " ").unwrap_err(),
+ zb.validate_sftp_file("f.csv", true, None, "").unwrap_err(),
+ zb.score_sftp_file("f.csv", true, None, "").unwrap_err(),
+ zb.email_finder_sftp_file("f.csv", true, None, "").unwrap_err(),
+ zb.domain_search_sftp_file("f.csv", true, None, "").unwrap_err(),
+ ] {
+ assert!(error.to_string().contains("return_url"), "{error}");
+ }
+
assert!(zb
.export_sftp_file(" ", None)
.unwrap_err()
diff --git a/tests/validation/batch_validation.rs b/tests/validation/batch_validation.rs
index 58cfbc5..ccf8573 100644
--- a/tests/validation/batch_validation.rs
+++ b/tests/validation/batch_validation.rs
@@ -58,7 +58,7 @@ fn test_batch_validation_bad_request() {
mock.assert();
let zb_error = validation.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
diff --git a/tests/validation/simple_validation.rs b/tests/validation/simple_validation.rs
index fd3208a..9de281c 100644
--- a/tests/validation/simple_validation.rs
+++ b/tests/validation/simple_validation.rs
@@ -50,7 +50,7 @@ fn test_simple_validation_bad_request() {
mock.assert();
let zb_error = validation.unwrap_err();
- let ZBError::ExplicitError(_) = zb_error else {
+ let ZBError::ApiError { .. } = zb_error else {
panic!("unexpected error: {:#?}", zb_error);
};
}
From 618ed2fe81e6de23368c7d3e36d7d0b17f7eb901 Mon Sep 17 00:00:00 2001
From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com>
Date: Sat, 24 Oct 2026 06:40:50 +0400
Subject: [PATCH 12/17] feat: add a Warmup API client
The Warmup API is a separate product: its own host, its own team-scoped
key, and an API-Key header rather than an api_key parameter. ZeroBounce
stays the validation client; ZeroBounceWarmup covers all 26 documented
endpoints, including the two CSV downloads.
Two live findings are recorded at the call site: check-domain-dmarc
takes an email address, not the domain the docs describe, and a failed
SMTP login comes back as an HTTP 500. The SMTP username and password are
masked out of every error from that method.
Co-Authored-By: Claude Opus 5
---
src/lib.rs | 2 +
src/warmup.rs | 690 ++++++++++++++++++++++++++++++++++++++++
tests/offline_api.rs | 1 +
tests/offline_warmup.rs | 667 ++++++++++++++++++++++++++++++++++++++
4 files changed, 1360 insertions(+)
create mode 100644 src/warmup.rs
create mode 100644 tests/offline_warmup.rs
diff --git a/src/lib.rs b/src/lib.rs
index 1ad5de1..1348265 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,6 @@
pub mod api;
pub mod utility;
+pub mod warmup;
use std::collections::HashMap;
use std::time::Duration;
@@ -12,6 +13,7 @@ pub use crate::utility::structures::validate_enums::{ZBValidateStatus, ZBValidat
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};
+pub use crate::warmup::{EmailContent, ManagedSending, OnboardingModel, ZeroBounceWarmup};
// 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).
diff --git a/src/warmup.rs b/src/warmup.rs
new file mode 100644
index 0000000..325cf90
--- /dev/null
+++ b/src/warmup.rs
@@ -0,0 +1,690 @@
+//! Client for the ZeroBounce Warmup API ().
+//!
+//! The Warmup API is separate from the email validation API: its own host, its
+//! own team-scoped key, and an `API-Key` header instead of an `api_key`
+//! parameter. Use [`ZeroBounceWarmup`] with a Warmup API key; [`crate::ZeroBounce`]
+//! stays for the validation API key.
+
+use std::collections::HashMap;
+use std::time::Duration;
+
+use chrono::NaiveDate;
+use serde_json::{Map, Value};
+
+use crate::utility::http_error::message_from_body;
+use crate::utility::{ZBError, ZBResult};
+
+const DEFAULT_BASE_URL: &str = "https://warmup-api.zerobounce.net";
+const BASE_PATH: &str = "/api/v1/warmup";
+
+/// Required by the schema but always replaced with the key's own team.
+const ANY_TEAM_ID: i64 = 1;
+
+/// How a sending account is run.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum OnboardingModel {
+ /// You run the mailbox yourself.
+ SelfManaged,
+ /// ZeroBounce provisions and runs the mailbox.
+ ZbManaged,
+}
+
+impl OnboardingModel {
+ fn as_str(self) -> &'static str {
+ match self {
+ OnboardingModel::SelfManaged => "self_managed",
+ OnboardingModel::ZbManaged => "zb_managed",
+ }
+ }
+}
+
+/// What ZeroBounce should send on your behalf, for `zb_managed` onboarding.
+#[derive(Debug, Clone, Default)]
+pub struct EmailContent {
+ /// Any of `transactional`, `newsletter`, `marketing`.
+ pub types_of_emails: Vec,
+ pub industry: Option,
+ pub company_name: Option,
+ pub website: Option,
+}
+
+/// When ZeroBounce should send, for `zb_managed` onboarding.
+#[derive(Debug, Clone, Default)]
+pub struct ManagedSending {
+ /// -11 to 14.
+ pub timezone_offset: Option,
+ pub warmup_start_date: Option,
+ pub daily_limit: Option,
+}
+
+const EMAIL_TYPES: [&str; 3] = ["transactional", "newsletter", "marketing"];
+
+#[derive(Debug)]
+pub struct ZeroBounceWarmup {
+ api_key: String,
+ root: String,
+ client: reqwest::blocking::Client,
+}
+
+impl ZeroBounceWarmup {
+ /// A client for the default Warmup host.
+ pub fn new(api_key: &str) -> ZBResult {
+ Self::with_base_url(api_key, DEFAULT_BASE_URL)
+ }
+
+ /// A client for a custom https base URL.
+ pub fn with_base_url(api_key: &str, base_url: &str) -> ZBResult {
+ if api_key.trim().is_empty() {
+ return Err(ZBError::explicit("api_key is required"));
+ }
+ // A key with surrounding whitespace or control characters would be
+ // quoted, unredacted, in the HTTP library's own error.
+ if api_key != api_key.trim() || api_key.chars().any(|c| c.is_control()) {
+ return Err(ZBError::explicit(
+ "api_key has surrounding whitespace or control characters",
+ ));
+ }
+ // https only, with the same loopback exception the validation client
+ // makes so the offline suite can point at a local fake.
+ crate::parse_request_url(base_url)
+ .map_err(|_| ZBError::explicit("base_url must be an https:// URL"))?;
+ Ok(ZeroBounceWarmup {
+ api_key: api_key.to_string(),
+ root: format!("{}{BASE_PATH}", base_url.trim_end_matches('/')),
+ client: reqwest::blocking::Client::builder()
+ .timeout(Duration::from_secs(120))
+ .redirect(reqwest::redirect::Policy::none())
+ .build()
+ .unwrap_or_else(|_| reqwest::blocking::Client::default()),
+ })
+ }
+
+ // -- transport -----------------------------------------------------------
+
+ /// Replace every form of a secret in a message with `***`.
+ fn redact(&self, text: &str, secrets: &[&str]) -> String {
+ let mut value = text.to_string();
+ for secret in std::iter::once(self.api_key.as_str()).chain(secrets.iter().copied()) {
+ if secret.is_empty() {
+ continue;
+ }
+ value = value.replace(secret, "***");
+ }
+ value
+ }
+
+ fn send(
+ &self,
+ method: &str,
+ path: &str,
+ query: &HashMap<&str, String>,
+ body: Option<&Value>,
+ secrets: &[&str],
+ ) -> ZBResult<(u16, String, String)> {
+ let url = crate::parse_request_url(&format!("{}{path}", self.root))?;
+ let mut request = match method {
+ "POST" => self.client.post(url),
+ "PUT" => self.client.put(url),
+ "DELETE" => self.client.delete(url),
+ _ => self.client.get(url),
+ }
+ .header("API-Key", self.api_key.as_str())
+ .header(reqwest::header::ACCEPT, "application/json")
+ .query(query);
+
+ if let Some(body) = body {
+ // reqwest's json() helper needs its "json" feature, which this
+ // crate does not enable, so the body and content type are set here.
+ let encoded = serde_json::to_string(body)
+ .map_err(|_| ZBError::explicit("Invalid parameter: request body"))?;
+ request = request
+ .header(reqwest::header::CONTENT_TYPE, "application/json")
+ .body(encoded);
+ }
+
+ let response = request
+ .send()
+ .map_err(|error| ZBError::ExplicitError(self.redact(&error.to_string(), secrets)))?;
+ 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 text = response
+ .text()
+ .map_err(|error| ZBError::ExplicitError(self.redact(&error.to_string(), secrets)))?;
+
+ if (300..400).contains(&status) {
+ return Err(ZBError::api(
+ status,
+ "ZeroBounce returned a redirect, which this client does not follow.",
+ ));
+ }
+ if status > 299 {
+ return Err(ZBError::api(
+ status,
+ self.redact(&message_from_body(status, &content_type, &text), secrets),
+ ));
+ }
+ Ok((status, content_type, text))
+ }
+
+ /// The JSON payload of a success, or `None` for the documented empty
+ /// successes (204, and an empty 200).
+ fn json(
+ &self,
+ method: &str,
+ path: &str,
+ query: &HashMap<&str, String>,
+ body: Option<&Value>,
+ secrets: &[&str],
+ ) -> ZBResult