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
{ + self.generate_multipart_with(true) + } + + /// `include_person_columns` is true for validation sendfile. Scoring sendfile omits them. + pub(crate) fn generate_multipart_with(&self, include_person_columns: bool) -> ZBResult { let content_part = self.file_content_multipart()?; let mut multipart_form = Form::new() .part("file", content_part) .text("has_header_row", self.has_header_row.to_string()) .text("remove_duplicate", self.remove_duplicate.to_string()) - .text("email_address_column", self.email_address_column.to_string()); - - if let Some(amount) = self.first_name_column { - multipart_form = multipart_form.text("first_name_column", amount.to_string()); - } - if let Some(amount) = self.last_name_column { - multipart_form = multipart_form.text("last_name_column", amount.to_string()); - } - if let Some(amount) = self.gender_column { - multipart_form = multipart_form.text("gender_column", amount.to_string()); - } - if let Some(amount) = self.ip_address_column { - multipart_form = multipart_form.text("ip_address_column", amount.to_string()); + .text( + "email_address_column", + self.email_address_column.to_string(), + ); + + if include_person_columns { + if let Some(amount) = self.first_name_column { + multipart_form = multipart_form.text("first_name_column", amount.to_string()); + } + if let Some(amount) = self.last_name_column { + multipart_form = multipart_form.text("last_name_column", amount.to_string()); + } + if let Some(amount) = self.gender_column { + multipart_form = multipart_form.text("gender_column", amount.to_string()); + } + if let Some(amount) = self.ip_address_column { + multipart_form = multipart_form.text("ip_address_column", amount.to_string()); + } } if let Some(url) = &self.return_url { multipart_form = multipart_form.text("return_url", url.clone()); @@ -254,20 +258,17 @@ impl ZBFile { } } - #[cfg(test)] - mod test { - use chrono::{NaiveDate, NaiveTime, NaiveDateTime}; - use serde_json::{Result as SerdeResult, from_str}; + use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; + use serde_json::{from_str, Result as SerdeResult}; use super::*; - use crate::utility::mock_constants::BULK_VALIDATION_SUBMIT_OK; - use crate::utility::mock_constants::BULK_VALIDATION_STATUS_OK; - use crate::utility::mock_constants::BULK_VALIDATION_STATUS_DELETED; - use crate::utility::mock_constants::BULK_VALIDATION_RESULT_DELETED; use crate::utility::mock_constants::BULK_VALIDATION_DELETE_OK; - + use crate::utility::mock_constants::BULK_VALIDATION_RESULT_DELETED; + use crate::utility::mock_constants::BULK_VALIDATION_STATUS_DELETED; + use crate::utility::mock_constants::BULK_VALIDATION_STATUS_OK; + use crate::utility::mock_constants::BULK_VALIDATION_SUBMIT_OK; #[test] fn test_parsing_file_submit_response_ok() { @@ -275,7 +276,7 @@ mod test { assert!(validation.is_ok()); let validation_obj = validation.unwrap(); - assert_eq!(validation_obj.success, true); + assert!(validation_obj.success); assert!(validation_obj.file_id.is_some()); assert!(validation_obj.file_name.is_some()); assert_eq!(validation_obj.message, "File Accepted"); @@ -292,7 +293,7 @@ mod test { ); let file_status_obj = file_status.unwrap(); - assert_eq!(file_status_obj.success, true); + assert!(file_status_obj.success); assert_eq!(file_status_obj.complete_percentage, 100.); assert_eq!(file_status_obj.upload_date.naive_utc(), expected_date_time); assert!(file_status_obj.return_url.is_some()); @@ -310,12 +311,11 @@ mod test { ); let file_status_obj = file_status.unwrap(); - assert_eq!(file_status_obj.success, true); + assert!(file_status_obj.success); assert_eq!(file_status_obj.complete_percentage, 0.); assert_eq!(file_status_obj.upload_date.naive_utc(), expected_date_time); assert!(file_status_obj.return_url.is_none()); assert!(file_status_obj.error_reason.is_some()); - } #[test] @@ -324,7 +324,7 @@ mod test { assert!(feedback.is_ok()); let feedback_obj = feedback.unwrap(); - assert_eq!(feedback_obj.success, false); + assert!(!(feedback_obj.success)); assert!(feedback_obj.file_id.is_none()); assert!(feedback_obj.file_name.is_none()); } @@ -335,10 +335,9 @@ mod test { assert!(feedback.is_ok()); let feedback_obj = feedback.unwrap(); - assert_eq!(feedback_obj.success, true); + assert!(feedback_obj.success); assert!(feedback_obj.file_id.is_some()); assert!(feedback_obj.file_name.is_some()); assert_eq!(feedback_obj.message, "File Deleted"); } - } diff --git a/tests/bulk/ai_scoring.rs b/tests/bulk/ai_scoring.rs index 84e4c27..614f0ab 100644 --- a/tests/bulk/ai_scoring.rs +++ b/tests/bulk/ai_scoring.rs @@ -23,9 +23,9 @@ fn test_ai_scoring_submit_invalid_json() { assert!(response.is_err()); let zb_error = response.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] @@ -79,9 +79,9 @@ fn test_ai_scoring_status_invalid_json() { assert!(response.is_err()); let zb_error = response.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] @@ -217,9 +217,9 @@ fn test_ai_scoring_delete_invalid_json() { assert!(response.is_err()); let zb_error = response.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/bulk/validation.rs b/tests/bulk/validation.rs index 70de2bc..359d62f 100644 --- a/tests/bulk/validation.rs +++ b/tests/bulk/validation.rs @@ -23,9 +23,9 @@ fn test_bulk_validate_submit_invalid_json() { assert!(response.is_err()); let zb_error = response.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] @@ -79,9 +79,9 @@ fn test_bulk_validate_status_invalid_json() { assert!(response.is_err()); let zb_error = response.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] @@ -247,9 +247,9 @@ fn test_bulk_validate_delete_invalid_json() { assert!(response.is_err()); let zb_error = response.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/generic_endpoints/activity_data.rs b/tests/generic_endpoints/activity_data.rs index 8257243..57b6cec 100644 --- a/tests/generic_endpoints/activity_data.rs +++ b/tests/generic_endpoints/activity_data.rs @@ -30,9 +30,9 @@ fn test_activity_data_invalid_json() { mock.assert(); let zb_error = activity_data.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/generic_endpoints/api_usage.rs b/tests/generic_endpoints/api_usage.rs index 573409c..6ecd4e2 100644 --- a/tests/generic_endpoints/api_usage.rs +++ b/tests/generic_endpoints/api_usage.rs @@ -31,9 +31,9 @@ fn test_api_usage_invalid_json() { mock.assert(); let zb_error = api_usage.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/generic_endpoints/domain_search_v2.rs b/tests/generic_endpoints/domain_search_v2.rs index fde7989..79af7ea 100644 --- a/tests/generic_endpoints/domain_search_v2.rs +++ b/tests/generic_endpoints/domain_search_v2.rs @@ -96,9 +96,9 @@ fn test_domain_search_v2_invalid_json() { mock.assert(); let zb_error = result.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/generic_endpoints/find_email_v2.rs b/tests/generic_endpoints/find_email_v2.rs index 8047bff..0f648ea 100644 --- a/tests/generic_endpoints/find_email_v2.rs +++ b/tests/generic_endpoints/find_email_v2.rs @@ -121,9 +121,9 @@ fn test_find_email_v2_invalid_json() { mock.assert(); let zb_error = result.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/generic_endpoints/find_mail.rs b/tests/generic_endpoints/find_mail.rs index 6c8ac35..0c79a5b 100644 --- a/tests/generic_endpoints/find_mail.rs +++ b/tests/generic_endpoints/find_mail.rs @@ -34,9 +34,9 @@ fn test_find_mail_invalid_json() { mock.assert(); let zb_error = find_mail_res.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] @@ -137,9 +137,9 @@ fn test_domain_search_invalid_json() { mock.assert(); let zb_error = domain_search_res.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/generic_endpoints/get_credits.rs b/tests/generic_endpoints/get_credits.rs index 1264ba1..19abb5a 100644 --- a/tests/generic_endpoints/get_credits.rs +++ b/tests/generic_endpoints/get_credits.rs @@ -29,9 +29,9 @@ fn test_credits_invalid_json() { mock.assert(); let credits_error = credits.unwrap_err(); - let ZBError::JsonError(_) = credits_error else { - panic!("unexpected error: {:#?}", credits_error); - }; + let message = credits_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] diff --git a/tests/offline_api.rs b/tests/offline_api.rs new file mode 100644 index 0000000..cda611e --- /dev/null +++ b/tests/offline_api.rs @@ -0,0 +1,925 @@ +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use chrono::NaiveDate; +use zero_bounce::utility::structures::bulk::{ + zb_download_type, ZBBulkResponse, ZBFile, ZBGetFileOptions, +}; +use zero_bounce::utility::structures::validate_options::ZBValidateOptions; +use zero_bounce::utility::BULK_URI; +use zero_bounce::{ApiBaseUrl, ZeroBounce}; + +struct Recorded { + method: String, + path: String, + api_key: String, + fields: HashMap, +} + +struct Fake { + records: Mutex>, +} + +impl Fake { + fn take(&self) -> Vec { + std::mem::take(&mut *self.records.lock().unwrap()) + } + fn len(&self) -> usize { + self.records.lock().unwrap().len() + } +} + +fn html_page() -> String { + let mut page = String::from("Access denied | zerobounce.net | Zero Bounce"); + for _ in 0..20000 { + page.push_str("

blocked

"); + } + page.push_str(""); + page +} + +fn json_escape(value: &str) -> String { + serde_json::to_string(value).unwrap() +} + +fn success_body(path: &str, key: &str) -> (u16, &'static str, String) { + let quoted = json_escape(key); + if path.contains("getfile") { + return ( + 200, + "application/octet-stream", + format!("email\n{key}@example.com\n"), + ); + } + if path.contains("listS3files") { + return ( + 200, + "application/json", + format!(r#"[{{"name":{quoted},"size":1,"last_modified":""}}]"#), + ); + } + if path.contains("validatebatch") { + return ( + 200, + "application/json", + format!( + r#"{{"email_batch":[{{"address":{quoted},"status":"valid","sub_status":"","free_email":false,"did_you_mean":null,"account":null,"domain":null,"domain_age_days":null,"active_in_days":null,"active_first_seen":null,"smtp_provider":null,"catchall_domain":null,"mx_record":null,"mx_found":null,"firstname":null,"lastname":null,"gender":null,"country":null,"region":null,"city":null,"zipcode":null,"processed_at":"2023-04-18 12:09:39.074"}}],"errors":[]}}"# + ), + ); + } + if path.ends_with("/validate") { + return ( + 200, + "application/json", + format!( + r#"{{"address":{quoted},"status":"valid","sub_status":"","free_email":false,"did_you_mean":null,"account":null,"domain":null,"domain_age_days":null,"active_in_days":null,"active_first_seen":null,"smtp_provider":null,"catchall_domain":null,"mx_record":null,"mx_found":null,"firstname":null,"lastname":null,"gender":null,"country":null,"region":null,"city":null,"zipcode":null,"processed_at":"2023-04-18 12:09:39.074"}}"# + ), + ); + } + if path.ends_with("/scoring") { + return ( + 200, + "application/json", + format!(r#"{{"email":{quoted},"score":10}}"#), + ); + } + if path.contains("getcredits") { + return (200, "application/json", r#"{"Credits":"7"}"#.to_string()); + } + if path.contains("getapiusage") { + return (200, "application/json", r#"{"total":1,"status_valid":0,"status_invalid":0,"status_catch_all":0,"status_do_not_mail":0,"status_spamtrap":0,"status_unknown":0,"sub_status_toxic":0,"sub_status_disposable":0,"sub_status_role_based":0,"sub_status_possible_trap":0,"sub_status_global_suppression":0,"sub_status_timeout_exceeded":0,"sub_status_mail_server_temporary_error":0,"sub_status_mail_server_did_not_respond":0,"sub_status_greylisted":0,"sub_status_antispam_system":0,"sub_status_does_not_accept_mail":0,"sub_status_exception_occurred":0,"sub_status_failed_syntax_check":0,"sub_status_mailbox_not_found":0,"sub_status_unroutable_ip_address":0,"sub_status_possible_typo":0,"sub_status_no_dns_entries":0,"sub_status_role_based_catch_all":0,"sub_status_accept_all":0,"sub_status_mailbox_quota_exceeded":0,"sub_status_forcible_disconnect":0,"sub_status_failed_smtp_connection":0,"sub_status_mx_forward":0,"sub_status_alternate":0,"sub_status_allowed":0,"sub_status_blocked":0,"sub_status_gold":0,"sub_status_role_based_accept_all":0,"start_date":"1/1/2018","end_date":"1/31/2018"}"#.to_string()); + } + if path.ends_with("/activity") { + return ( + 200, + "application/json", + r#"{"found":true,"active_in_days":"3"}"#.to_string(), + ); + } + if path.contains("filestatus") { + return ( + 200, + "application/json", + format!( + r#"{{"success":true,"file_id":{quoted},"file_name":"f.csv","file_status":"Complete","complete_percentage":"100%","return_url":null,"upload_date":"2023-04-26T17:52:23Z","error_reason":null}}"# + ), + ); + } + if path.contains("guessformat") { + return ( + 200, + "application/json", + format!( + r#"{{"email":{quoted},"domain":{quoted},"format":"","status":"","sub_status":"","confidence":"","did_you_mean":"","failure_reason":"","other_domain_formats":[]}}"# + ), + ); + } + ( + 200, + "application/json", + format!( + r#"{{"success":true,"message":"ok","file_name":"f.csv","file_id":{quoted},"email":{quoted},"domain":{quoted},"echo_api_key":{quoted}}}"# + ), + ) +} + +fn answer(path: &str, key: &str) -> (u16, &'static str, String) { + let page = html_page(); + if key.starts_with("html-403-") { + return (403, "text/html; charset=UTF-8", page); + } + if key.starts_with("html-200-") { + return (200, "text/html; charset=UTF-8", page); + } + if key.starts_with("empty-") { + return (200, "application/json", String::new()); + } + if key.starts_with("malformed-") { + return ( + 200, + "application/json", + format!("<<>>{}", "x".repeat(5000)), + ); + } + if key.starts_with("cf-json-") { + return (403, "application/json", r#"{"type":"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1020/","title":"Error 1020: Access denied","status":403,"detail":"The request was blocked by a Cloudflare firewall rule configured by the site owner.","error_code":1020,"cloudflare_error":true,"what_you_should_do":"Do not retry. This block is intentional."}"#.to_string()); + } + if key.starts_with("double-error-") { + return ( + 400, + "text/plain", + "Error: Error: api_key is invalid".to_string(), + ); + } + if path.contains("validatebatch") && key.starts_with("batch-account-") { + return (200, "application/json", r#"{"email_batch":[],"errors":[{"error":"Invalid API Key or your account ran out of credits","email_address":"all"}]}"#.to_string()); + } + if path.contains("validatebatch") && key.starts_with("batch-partial-") { + return (200, "application/json", r#"{"email_batch":[{"address":"good@example.com","status":"valid","sub_status":"","free_email":false,"did_you_mean":null,"account":null,"domain":null,"domain_age_days":null,"active_in_days":null,"active_first_seen":null,"smtp_provider":null,"catchall_domain":null,"mx_record":null,"mx_found":null,"firstname":null,"lastname":null,"gender":null,"country":null,"region":null,"city":null,"zipcode":null,"processed_at":"2023-04-18 12:09:39.074"}],"errors":[{"error":"Invalid email address","email_address":"bad@"}]}"#.to_string()); + } + success_body(path, key) +} + +fn url_decode(value: &str) -> String { + let mut out = Vec::new(); + let bytes = value.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => out.push(b' '), + b'%' if i + 2 < bytes.len() => { + let hex = u8::from_str_radix(&value[i + 1..i + 3], 16).unwrap_or(0); + out.push(hex); + i += 2; + } + byte => out.push(byte), + } + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn parse_query(query: &str, fields: &mut HashMap) { + if query.is_empty() { + return; + } + for pair in query.split('&') { + let mut parts = pair.splitn(2, '='); + let key = url_decode(parts.next().unwrap_or("")); + let value = url_decode(parts.next().unwrap_or("")); + fields.insert(key, value); + } +} + +fn flatten_json(body: &str, fields: &mut HashMap) { + if let Ok(value) = serde_json::from_str::(body) { + if let Some(object) = value.as_object() { + for (key, item) in object { + let text = match item { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Bool(flag) => { + if *flag { + "true".into() + } else { + "false".into() + } + } + other => other.to_string(), + }; + fields.insert(key.clone(), text); + } + } + } +} + +fn record_request(head: &str, body: &[u8]) -> Recorded { + let mut lines = head.split("\r\n"); + let request_line = lines.next().unwrap_or(""); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or("").to_string(); + let target = parts.next().unwrap_or("/"); + let (path, query) = target.split_once('?').unwrap_or((target, "")); + let mut fields = HashMap::new(); + parse_query(query, &mut fields); + let mut content_type = 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(); + } + } + let body_text = String::from_utf8_lossy(body); + if content_type.contains("application/json") || body_text.trim_start().starts_with('{') { + flatten_json(&body_text, &mut fields); + } else if content_type.contains("application/x-www-form-urlencoded") + || body_text.contains('=') && !content_type.contains("multipart") + { + parse_query(&body_text, &mut fields); + } else if content_type.contains("multipart") { + for part in body_text.split("\r\n\r\n") { + if let Some(header) = part.split("\r\n").next() { + if let Some(name_at) = header.find("name=\"") { + let rest = &header[name_at + 6..]; + if let Some(end) = rest.find('"') { + let name = rest[..end].to_string(); + let value = if header.contains("filename=") { + "".to_string() + } else { + part.split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .trim_end_matches("--") + .trim() + .to_string() + }; + fields.insert(name, value); + } + } + } + } + // The split above is coarse. Walk the raw body for name= fields. + let raw = body_text.as_ref(); + let mut search_from = 0; + while let Some(rel) = raw[search_from..].find("name=\"") { + let start = search_from + rel + 6; + if let Some(end_rel) = raw[start..].find('"') { + let name = raw[start..start + end_rel].to_string(); + let after = start + end_rel; + let filename = + raw[after..].starts_with("\"; filename=") || raw[after..].contains("filename="); + if let Some(sep) = raw[after..].find("\r\n\r\n") { + let value_start = after + sep + 4; + let value_end = raw[value_start..] + .find("\r\n--") + .map(|i| value_start + i) + .unwrap_or(raw.len()); + let value = if filename || raw[after..value_start].contains("filename=") { + "".to_string() + } else { + raw[value_start..value_end].to_string() + }; + fields.insert(name, value); + search_from = value_end; + } else { + break; + } + } else { + break; + } + } + } + let api_key = fields.get("api_key").cloned().unwrap_or_default(); + Recorded { + method, + path: path.to_string(), + api_key, + fields, + } +} + +fn handle(mut stream: TcpStream, fake: Arc) { + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + let mut buffer = Vec::new(); + let mut temp = [0u8; 8192]; + loop { + match stream.read(&mut temp) { + Ok(0) | Err(_) => break, + Ok(n) => { + buffer.extend_from_slice(&temp[..n]); + if let Some(pos) = buffer.windows(4).position(|w| w == b"\r\n\r\n") { + let header = String::from_utf8_lossy(&buffer[..pos]).to_string(); + let mut length = 0usize; + for line in header.split("\r\n") { + if let Some(value) = + line.to_ascii_lowercase().strip_prefix("content-length:") + { + length = value.trim().parse().unwrap_or(0); + } + } + let body_start = pos + 4; + while buffer.len() < body_start + length { + match stream.read(&mut temp) { + Ok(0) | Err(_) => break, + Ok(n) => buffer.extend_from_slice(&temp[..n]), + } + } + let body = buffer + .get(body_start..body_start + length) + .unwrap_or(&[]) + .to_vec(); + let recorded = record_request(&header, &body); + let (status, content_type, payload) = answer(&recorded.path, &recorded.api_key); + fake.records.lock().unwrap().push(recorded); + thread::sleep(Duration::from_millis((std::process::id() as u64 % 7) + 1)); + let response = format!( + "HTTP/1.1 {status} OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(payload.as_bytes()); + break; + } + } + } + } +} + +fn start_fake() -> (u16, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let fake = Arc::new(Fake { + records: Mutex::new(Vec::new()), + }); + let shared = fake.clone(); + thread::spawn(move || { + for incoming in listener.incoming() { + if let Ok(stream) = incoming { + let shared = shared.clone(); + thread::spawn(move || handle(stream, shared)); + } + } + }); + (port, fake) +} + +fn client(port: u16, key: &str) -> ZeroBounce { + let mut zb = ZeroBounce::new("placeholder"); + zb.api_key = key.to_string(); + 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"); + zb.client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + zb +} + +fn only(fake: &Fake) -> Recorded { + let records = fake.take(); + assert_eq!(records.len(), 1, "requests"); + records.into_iter().next().unwrap() +} + +fn assert_short(err: &impl std::fmt::Display) { + let message = err.to_string(); + assert!(message.len() < 500, "{message}"); + for forbidden in [ + "blocked

", + "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> { + let (status, content_type, text) = self.send(method, path, query, body, secrets)?; + if status == 204 || text.trim().is_empty() { + return Ok(None); + } + match serde_json::from_str::(&text) { + Ok(value) => Ok(Some(value)), + Err(_) => Err(ZBError::api( + status, + self.redact(&message_from_body(status, &content_type, &text), secrets), + )), + } + } + + fn get(&self, path: &str, query: &HashMap<&str, String>) -> ZBResult> { + self.json("GET", path, query, None, &[]) + } + + fn write(&self, method: &str, path: &str, body: Value) -> ZBResult> { + self.json(method, path, &HashMap::new(), Some(&body), &[]) + } + + /// A CSV download, returned as text. + fn csv(&self, path: &str, query: &HashMap<&str, String>) -> ZBResult { + let (status, content_type, text) = self.send("GET", path, query, None, &[])?; + if text.trim().is_empty() || content_type.to_ascii_lowercase().contains("application/json") + { + return Err(ZBError::api( + status, + message_from_body(status, &content_type, &text), + )); + } + Ok(text) + } + + // -- argument checks ----------------------------------------------------- + + fn required<'a>(value: &'a str, name: &str) -> ZBResult<&'a str> { + if value.trim().is_empty() { + return Err(ZBError::ExplicitError(format!("{name} is required"))); + } + Ok(value) + } + + fn in_range(value: i64, low: i64, high: i64, name: &str) -> ZBResult { + if !(low..=high).contains(&value) { + return Err(ZBError::ExplicitError(format!( + "{name} must be {low} to {high}" + ))); + } + Ok(value) + } + + fn engagement_body( + c_from: &str, + reply_pct: i64, + important_pct: i64, + click_pct: i64, + ) -> ZBResult { + Self::required(c_from, "c_from")?; + Self::in_range(reply_pct, 5, 45, "reply_pct")?; + Self::in_range(important_pct, 5, 40, "important_pct")?; + Self::in_range(click_pct, 0, 70, "click_pct")?; + if (click_pct as f64) > important_pct as f64 * 1.75 { + return Err(ZBError::explicit( + "click_pct must be at most important_pct x 1.75", + )); + } + Ok(Value::Object(Map::from_iter([ + ("c_from".into(), Value::String(c_from.to_string())), + ("reply_pct".into(), Value::from(reply_pct)), + ("important_pct".into(), Value::from(important_pct)), + ("click_pct".into(), Value::from(click_pct)), + ]))) + } + + // -- sending accounts ---------------------------------------------------- + + /// Every sending account of the key's team. `GET /sending-accounts` + pub fn list_sending_accounts(&self) -> ZBResult> { + self.get("/sending-accounts", &HashMap::new()) + } + + /// One sending account. `GET /sending-account` + pub fn get_sending_account(&self, from_address: &str) -> ZBResult> { + Self::required(from_address, "from_address")?; + self.get( + "/sending-account", + &HashMap::from([("from_address", from_address.to_string())]), + ) + } + + /// Add a from-address to warmup. `POST /sending-accounts` + /// + /// `SelfManaged` runs your own mailbox and needs `engagement_rule_id` 0-4. + /// `ZbManaged` has ZeroBounce provision it and takes `email_content` and + /// `managed_sending`; a 200 means accepted, and the mailbox is provisioned + /// afterwards. + pub fn add_sending_account( + &self, + from_address: &str, + onboarding_model: OnboardingModel, + engagement_rule_id: Option, + ignore_conflict: Option, + email_content: Option<&EmailContent>, + managed_sending: Option<&ManagedSending>, + ) -> ZBResult> { + Self::required(from_address, "from_address")?; + let mut body = Map::from_iter([ + ("from_address".into(), Value::String(from_address.into())), + ( + "onboarding_model".into(), + Value::String(onboarding_model.as_str().into()), + ), + ]); + + if onboarding_model == OnboardingModel::SelfManaged { + let Some(rule) = engagement_rule_id else { + return Err(ZBError::explicit( + "engagement_rule_id is required for self_managed", + )); + }; + body.insert( + "engagement_rule_id".into(), + Value::from(Self::in_range(rule, 0, 4, "engagement_rule_id")?), + ); + if email_content.is_some() || managed_sending.is_some() { + return Err(ZBError::explicit( + "email_content and managed_sending are for zb_managed", + )); + } + } + if let Some(flag) = ignore_conflict { + body.insert("ignore_conflict".into(), Value::Bool(flag)); + } + if let Some(content) = email_content { + let mut object = Map::new(); + if !content.types_of_emails.is_empty() { + for kind in &content.types_of_emails { + if !EMAIL_TYPES.contains(&kind.as_str()) { + return Err(ZBError::explicit( + "types_of_emails must be transactional, newsletter and/or marketing", + )); + } + } + object.insert( + "types_of_emails".into(), + Value::Array( + content + .types_of_emails + .iter() + .map(|kind| Value::String(kind.clone())) + .collect(), + ), + ); + } + for (key, value) in [ + ("industry", &content.industry), + ("company_name", &content.company_name), + ("website", &content.website), + ] { + if let Some(value) = value { + object.insert(key.into(), Value::String(value.clone())); + } + } + body.insert("email_content".into(), Value::Object(object)); + } + if let Some(sending) = managed_sending { + let mut object = Map::new(); + if let Some(offset) = sending.timezone_offset { + Self::in_range(offset as i64, -11, 14, "timezone_offset")?; + object.insert("timezone_offset".into(), Value::from(offset)); + } + if let Some(date) = sending.warmup_start_date { + object.insert( + "warmup_start_date".into(), + Value::String(date.format("%F").to_string()), + ); + } + if let Some(limit) = sending.daily_limit { + object.insert("daily_limit".into(), Value::from(limit)); + } + body.insert("managed_sending".into(), Value::Object(object)); + } + self.write("POST", "/sending-accounts", Value::Object(body)) + } + + /// Change an account's engagement rule, status and auto-enable flag. + /// `PUT /sending-accounts`. `status_id` is 0 to 8 (4 Paused, 5 Disabled). + pub fn update_sending_account( + &self, + from_address: &str, + engagement_rule_id: i64, + status_id: i64, + auto_enable: bool, + ) -> ZBResult> { + Self::required(from_address, "from_address")?; + let body = Value::Object(Map::from_iter([ + ("team_id".into(), Value::from(ANY_TEAM_ID)), + ("from_address".into(), Value::String(from_address.into())), + ( + "engagement_rule_id".into(), + Value::from(Self::in_range(engagement_rule_id, 0, 4, "engagement_rule_id")?), + ), + ( + "status_id".into(), + Value::from(Self::in_range(status_id, 0, 8, "status_id")?), + ), + ("auto_enable".into(), Value::Bool(auto_enable)), + ])); + self.write("PUT", "/sending-accounts", body) + } + + /// Remove a sending account. `DELETE /sending-accounts`. Destructive and final. + pub fn delete_sending_account(&self, from_address: &str) -> ZBResult> { + Self::required(from_address, "from_address")?; + self.json( + "DELETE", + "/sending-accounts", + &HashMap::from([("from_address", from_address.to_string())]), + None, + &[], + ) + } + + /// Move an account and its history to a new from-address. + /// `PUT /smtp-credentials` — despite the path, no SMTP credentials are stored. + pub fn change_from_address( + &self, + old_from_address: &str, + new_from_address: &str, + ) -> ZBResult> { + Self::required(old_from_address, "old_from_address")?; + Self::required(new_from_address, "new_from_address")?; + let body = Value::Object(Map::from_iter([ + ("team_id".into(), Value::from(ANY_TEAM_ID)), + ( + "old_from_address".into(), + Value::String(old_from_address.into()), + ), + ( + "new_from_address".into(), + Value::String(new_from_address.into()), + ), + ])); + self.write("PUT", "/smtp-credentials", body) + } + + /// Registered from-addresses. `GET /from-addresses` + /// + /// `search_from` takes letters, digits and `. - _ @ +` only. `offset` and + /// `limit` go on the wire as strings. + pub fn list_from_addresses( + &self, + search_from: Option<&str>, + offset: Option, + limit: Option, + ) -> ZBResult> { + let mut query = HashMap::new(); + if let Some(search) = search_from { + if !search + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".-_@+".contains(c)) + { + return Err(ZBError::explicit( + "search_from takes letters, digits and . - _ @ + only", + )); + } + query.insert("search_from", search.to_string()); + } + if let Some(offset) = offset { + query.insert("offset", offset.to_string()); + } + if let Some(limit) = limit { + query.insert("limit", limit.to_string()); + } + self.get("/from-addresses", &query) + } + + // -- engagement rules ---------------------------------------------------- + + /// -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. + pub fn get_engagement_rules(&self) -> ZBResult> { + self.get("/engagement-rules", &HashMap::new()) + } + + /// Set a standard rule (0 to 4) on an address. `PUT /engagement-rule` + pub fn set_engagement_rule(&self, c_from: &str, rule_id: i64) -> ZBResult> { + Self::required(c_from, "c_from")?; + let body = Value::Object(Map::from_iter([( + "rule_id".to_string(), + Value::from(Self::in_range(rule_id, 0, 4, "rule_id")?), + )])); + self.json( + "PUT", + "/engagement-rule", + &HashMap::from([("c_from", c_from.to_string())]), + Some(&body), + &[], + ) + } + + /// An address's custom rule, with `can_update` and `remaining_seconds`. + pub fn get_custom_engagement(&self, c_from: &str) -> ZBResult> { + Self::required(c_from, "c_from")?; + let path = format!( + "/custom-engagement/{}", + crate::utility::encode_path_segment(c_from.trim()) + ); + if c_from.trim() == "." || c_from.trim() == ".." { + return Err(ZBError::explicit("c_from is invalid")); + } + self.get(&path, &HashMap::new()) + } + + /// Create a custom rule. Starts a 7 day cooldown on updates. + pub fn create_custom_engagement( + &self, + c_from: &str, + reply_pct: i64, + important_pct: i64, + click_pct: i64, + ) -> ZBResult> { + let body = Self::engagement_body(c_from, reply_pct, important_pct, click_pct)?; + self.write("POST", "/custom-engagement", body) + } + + /// Update a custom rule. During the 7 day cooldown the API answers 429. + pub fn update_custom_engagement( + &self, + c_from: &str, + reply_pct: i64, + important_pct: i64, + click_pct: i64, + ) -> ZBResult> { + let body = Self::engagement_body(c_from, reply_pct, important_pct, click_pct)?; + self.write("PUT", "/custom-engagement", body) + } + + /// Delete a custom rule and fall back to standard rule 1-4. + pub fn delete_custom_engagement( + &self, + c_from: &str, + engagement_rule_id: i64, + ) -> ZBResult> { + Self::required(c_from, "c_from")?; + let body = Value::Object(Map::from_iter([ + ("c_from".to_string(), Value::String(c_from.into())), + ( + "engagement_rule_id".to_string(), + Value::from(Self::in_range(engagement_rule_id, 1, 4, "engagement_rule_id")?), + ), + ])); + self.write("DELETE", "/custom-engagement", body) + } + + // -- statistics, seeds and utilities ------------------------------------- + + /// The seed list. `GET /seeds` + pub fn get_seeds(&self) -> ZBResult> { + self.get("/seeds", &HashMap::new()) + } + + /// The seed list as CSV (columns `seed,provider`). `GET /seeds/download` + pub fn download_seeds(&self) -> ZBResult { + self.csv("/seeds/download", &HashMap::new()) + } + + /// Number of seeds. `GET /seed-count` + pub fn get_seed_count(&self) -> ZBResult> { + self.get("/seed-count", &HashMap::new()) + } + + /// Today's warmup progress. `GET /progress-today` + pub fn get_progress_today(&self) -> ZBResult> { + self.get("/progress-today", &HashMap::new()) + } + + /// Messages sent today. `GET /sent-today` + pub fn get_sent_today(&self) -> ZBResult> { + self.get("/sent-today", &HashMap::new()) + } + + /// Current daily volume. `GET /emails-per-day` + pub fn get_emails_per_day(&self) -> ZBResult> { + self.get("/emails-per-day", &HashMap::new()) + } + + /// Statistics for an address. An address with no history returns zeros. + pub fn get_statistics(&self, from_address: &str) -> ZBResult> { + Self::required(from_address, "from_address")?; + self.get( + "/statistics", + &HashMap::from([("from_address", from_address.to_string())]), + ) + } + + /// Daily statistics between two dates, inclusive. `GET /statistics-per-day` + pub fn get_statistics_per_day( + &self, + from_address: &str, + start_date: NaiveDate, + end_date: NaiveDate, + ) -> ZBResult> { + Self::required(from_address, "from_address")?; + if start_date > end_date { + return Err(ZBError::explicit("start_date is later than end_date")); + } + self.get( + "/statistics-per-day", + &HashMap::from([ + ("from_address", from_address.to_string()), + ("start_date", start_date.format("%F").to_string()), + ("end_date", end_date.format("%F").to_string()), + ]), + ) + } + + /// Detailed statistics for an address. `GET /statistics-detailed` + pub fn get_statistics_detailed(&self, c_from: &str) -> ZBResult> { + Self::required(c_from, "c_from")?; + self.get( + "/statistics-detailed", + &HashMap::from([("c_from", c_from.to_string())]), + ) + } + + /// An address's statistics as CSV. `GET /statistics/download` + pub fn download_statistics(&self, c_from: &str) -> ZBResult { + Self::required(c_from, "c_from")?; + self.csv( + "/statistics/download", + &HashMap::from([("c_from", c_from.to_string())]), + ) + } + + /// Statistics for one from-address. `GET /from-address-statistics` + pub fn get_from_address_statistics(&self, from_address: &str) -> ZBResult> { + Self::required(from_address, "from_address")?; + self.get( + "/from-address-statistics", + &HashMap::from([("from_address", from_address.to_string())]), + ) + } + + /// SPF, DKIM and DMARC status of a from-address's domain. + /// `GET /check-domain-dmarc` + /// + /// LIVE: pass an **email address**. The live API answers "Please provide a + /// valid email address" for a bare domain, although the docs describe one. + pub fn check_domain_dmarc( + &self, + from_address: &str, + dkim_selector: Option<&str>, + ) -> ZBResult> { + Self::required(from_address, "from_address")?; + if !from_address.contains('@') { + return Err(ZBError::explicit( + "from_address must be an email address, e.g. sender@example.com", + )); + } + let mut query = HashMap::from([("from_address", from_address.to_string())]); + if let Some(selector) = dkim_selector { + Self::required(selector, "dkim_selector")?; + query.insert("dkim_selector", selector.to_string()); + } + self.get("/check-domain-dmarc", &query) + } + + /// Check that SMTP credentials connect and hand off a message. + /// `POST /test-smtp-configuration` + /// + /// Nothing is stored, and the test message goes to a ZeroBounce test + /// recipient, not `from_address`. + /// + /// LIVE: the API answers any failed SMTP login with HTTP 500. That is an + /// API bug. The username and password are masked in every error from here. + pub fn test_smtp_configuration( + &self, + from_address: &str, + username: &str, + password: &str, + server: &str, + port: u16, + ) -> ZBResult> { + Self::required(from_address, "from_address")?; + Self::required(username, "username")?; + Self::required(password, "password")?; + Self::required(server, "server")?; + if port == 0 { + return Err(ZBError::explicit("port must be 1 to 65535")); + } + let body = Value::Object(Map::from_iter([ + ("from_address".to_string(), Value::String(from_address.into())), + ("username".to_string(), Value::String(username.into())), + ("password".to_string(), Value::String(password.into())), + ("server".to_string(), Value::String(server.into())), + ("port".to_string(), Value::from(port)), + ])); + self.json( + "POST", + "/test-smtp-configuration", + &HashMap::new(), + Some(&body), + &[password, username], + ) + } +} diff --git a/tests/offline_api.rs b/tests/offline_api.rs index 65a10df..9c030d8 100644 --- a/tests/offline_api.rs +++ b/tests/offline_api.rs @@ -21,6 +21,7 @@ struct Recorded { method: String, path: String, api_key: String, + #[allow(dead_code)] authorization: String, fields: HashMap, } diff --git a/tests/offline_warmup.rs b/tests/offline_warmup.rs new file mode 100644 index 0000000..632acd3 --- /dev/null +++ b/tests/offline_warmup.rs @@ -0,0 +1,667 @@ +//! Offline tests for the Warmup API client. A local fake records every request +//! and answers without the network. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use chrono::NaiveDate; +use serde_json::Value; +use zero_bounce::{EmailContent, ManagedSending, OnboardingModel, ZBError, ZeroBounceWarmup}; + +#[derive(Clone, Debug)] +struct Recorded { + method: String, + path: String, + query: HashMap, + api_key: String, + body: Option, +} + +struct Fake { + records: Mutex>, + reply: Mutex>, +} + +impl Fake { + fn take(&self) -> Vec { + std::mem::take(&mut *self.records.lock().unwrap()) + } + + fn only(&self) -> Recorded { + let records = self.take(); + assert_eq!(records.len(), 1, "expected one request, got {records:?}"); + records[0].clone() + } + + fn reply_with(&self, status: u16, content_type: &str, body: &str) { + *self.reply.lock().unwrap() = + Some((status, content_type.to_string(), body.to_string())); + } +} + +fn percent_decode(text: &str) -> String { + let bytes = text.replace('+', " ").into_bytes(); + let mut out = Vec::new(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let Ok(byte) = + u8::from_str_radix(std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or(""), 16) + { + out.push(byte); + index += 3; + continue; + } + } + out.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&out).to_string() +} + +fn handle(mut stream: TcpStream, fake: Arc) { + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + let mut buffer = Vec::new(); + let mut temp = [0u8; 8192]; + loop { + match stream.read(&mut temp) { + Ok(0) | Err(_) => break, + Ok(n) => { + buffer.extend_from_slice(&temp[..n]); + let Some(pos) = buffer.windows(4).position(|w| w == b"\r\n\r\n") else { + continue; + }; + let header = String::from_utf8_lossy(&buffer[..pos]).to_string(); + let mut length = 0usize; + let mut api_key = String::new(); + let mut lines = header.split("\r\n"); + let request_line = lines.next().unwrap_or("").to_string(); + for line in lines { + let lower = line.to_ascii_lowercase(); + if let Some(value) = lower.strip_prefix("content-length:") { + length = value.trim().parse().unwrap_or(0); + } + if lower.starts_with("api-key:") { + api_key = line["api-key:".len()..].trim().to_string(); + } + } + let body_start = pos + 4; + while buffer.len() < body_start + length { + match stream.read(&mut temp) { + Ok(0) | Err(_) => break, + Ok(n) => buffer.extend_from_slice(&temp[..n]), + } + } + let body_bytes = buffer + .get(body_start..body_start + length) + .unwrap_or(&[]) + .to_vec(); + + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or("").to_string(); + let target = parts.next().unwrap_or("/"); + let (path, query_string) = target.split_once('?').unwrap_or((target, "")); + let mut query = HashMap::new(); + for pair in query_string.split('&').filter(|p| !p.is_empty()) { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + query.insert(percent_decode(key), percent_decode(value)); + } + let body = if body_bytes.is_empty() { + None + } else { + serde_json::from_slice::(&body_bytes).ok() + }; + + fake.records.lock().unwrap().push(Recorded { + method, + path: path.to_string(), + query, + api_key: api_key.clone(), + body, + }); + + let (status, content_type, payload) = + fake.reply.lock().unwrap().take().unwrap_or(( + 200, + "application/json".to_string(), + format!(r#"{{"ok":true,"echo_api_key":"{api_key}"}}"#), + )); + let response = format!( + "HTTP/1.1 {status} OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(payload.as_bytes()); + break; + } + } + } +} + +fn start_fake() -> (u16, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let fake = Arc::new(Fake { + records: Mutex::new(Vec::new()), + reply: Mutex::new(None), + }); + let shared = fake.clone(); + thread::spawn(move || { + for incoming in listener.incoming().flatten() { + let shared = shared.clone(); + thread::spawn(move || handle(incoming, shared)); + } + }); + (port, fake) +} + +fn client(port: u16) -> ZeroBounceWarmup { + ZeroBounceWarmup::with_base_url("warmup-key", &format!("http://127.0.0.1:{port}")).unwrap() +} + +fn date(text: &str) -> NaiveDate { + NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap() +} + +// -- construction ------------------------------------------------------------ + +#[test] +fn the_key_travels_in_the_api_key_header() { + let (port, fake) = start_fake(); + let zb = client(port); + + zb.get_seed_count().unwrap(); + let request = fake.only(); + assert_eq!(request.path, "/api/v1/warmup/seed-count"); + assert_eq!(request.api_key, "warmup-key"); + // The key must never reach the query string, where it would be logged. + assert!(request.query.is_empty(), "{:?}", request.query); +} + +#[test] +fn a_key_that_is_empty_or_padded_is_rejected() { + assert!(ZeroBounceWarmup::new("").is_err()); + for bad in [" key ", "key\n", "key\tx", "key\u{0}"] { + let error = ZeroBounceWarmup::new(bad).unwrap_err(); + assert!(error.to_string().contains("whitespace"), "{error}"); + } +} + +#[test] +fn a_plain_http_base_url_is_rejected() { + let error = ZeroBounceWarmup::with_base_url("key", "http://warmup-api.zerobounce.net") + .unwrap_err(); + assert!(error.to_string().contains("https"), "{error}"); +} + +// -- paths and verbs --------------------------------------------------------- + +#[test] +fn every_endpoint_uses_its_documented_path_and_verb() { + let (port, fake) = start_fake(); + let zb = client(port); + + zb.list_sending_accounts().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/sending-accounts"); + zb.get_sending_account("a@b.com").unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/sending-account"); + zb.list_from_addresses(None, None, None).unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/from-addresses"); + zb.get_engagement_rules().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/engagement-rules"); + zb.get_custom_engagement("a@b.com").unwrap(); + assert_eq!( + fake.only().path, + "/api/v1/warmup/custom-engagement/a%40b.com" + ); + zb.get_seeds().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/seeds"); + zb.get_progress_today().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/progress-today"); + zb.get_sent_today().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/sent-today"); + zb.get_emails_per_day().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/emails-per-day"); + zb.get_statistics("a@b.com").unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/statistics"); + zb.get_statistics_detailed("a@b.com").unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/statistics-detailed"); + zb.get_from_address_statistics("a@b.com").unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/from-address-statistics"); + zb.check_domain_dmarc("a@b.com", None).unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/check-domain-dmarc"); + + zb.add_sending_account("a@b.com", OnboardingModel::SelfManaged, Some(0), None, None, None) + .unwrap(); + let request = fake.only(); + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/api/v1/warmup/sending-accounts"); + + zb.update_sending_account("a@b.com", 1, 4, true).unwrap(); + assert_eq!(fake.only().method, "PUT"); + zb.delete_sending_account("a@b.com").unwrap(); + assert_eq!(fake.only().method, "DELETE"); + zb.change_from_address("a@b.com", "c@d.com").unwrap(); + let request = fake.only(); + assert_eq!(request.method, "PUT"); + assert_eq!(request.path, "/api/v1/warmup/smtp-credentials"); + zb.set_engagement_rule("a@b.com", 2).unwrap(); + let request = fake.only(); + assert_eq!(request.method, "PUT"); + assert_eq!(request.path, "/api/v1/warmup/engagement-rule"); + zb.create_custom_engagement("a@b.com", 10, 10, 5).unwrap(); + let request = fake.only(); + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/api/v1/warmup/custom-engagement"); + zb.update_custom_engagement("a@b.com", 10, 10, 5).unwrap(); + assert_eq!(fake.only().method, "PUT"); + zb.delete_custom_engagement("a@b.com", 1).unwrap(); + assert_eq!(fake.only().method, "DELETE"); + zb.test_smtp_configuration("a@b.com", "u", "p", "smtp.example.com", 587) + .unwrap(); + let request = fake.only(); + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/api/v1/warmup/test-smtp-configuration"); +} + +#[test] +fn statistics_per_day_sends_both_dates() { + let (port, fake) = start_fake(); + let zb = client(port); + zb.get_statistics_per_day("a@b.com", date("2026-01-02"), date("2026-01-09")) + .unwrap(); + let request = fake.only(); + assert_eq!(request.path, "/api/v1/warmup/statistics-per-day"); + assert_eq!(request.query.get("start_date").unwrap(), "2026-01-02"); + assert_eq!(request.query.get("end_date").unwrap(), "2026-01-09"); +} + +// -- argument validation ----------------------------------------------------- + +#[test] +fn required_arguments_are_checked_before_any_request() { + let (port, fake) = start_fake(); + let zb = client(port); + + assert!(zb.get_sending_account("").is_err()); + assert!(zb.delete_sending_account(" ").is_err()); + assert!(zb.change_from_address("", "b@c.com").is_err()); + assert!(zb.change_from_address("a@b.com", "").is_err()); + assert!(zb.set_engagement_rule("", 1).is_err()); + assert!(zb.get_statistics("").is_err()); + assert!(zb.download_statistics("").is_err()); + assert!(zb.check_domain_dmarc("", None).is_err()); + assert!(fake.take().is_empty(), "nothing should reach the network"); +} + +#[test] +fn engagement_rule_ids_are_range_checked() { + let (port, fake) = start_fake(); + let zb = client(port); + for bad in [-1, 5] { + let error = zb.set_engagement_rule("a@b.com", bad).unwrap_err(); + assert!(error.to_string().contains("rule_id"), "{error}"); + } + assert!(fake.take().is_empty()); + for good in 0..=4 { + zb.set_engagement_rule("a@b.com", good).unwrap(); + assert_eq!(fake.only().body.unwrap()["rule_id"], good); + } +} + +#[test] +fn custom_engagement_percentages_are_range_checked() { + let (port, fake) = start_fake(); + let zb = client(port); + for (reply, important, click) in [ + (4, 10, 5), // reply_pct below 5 + (46, 10, 5), // reply_pct above 45 + (10, 4, 5), // important_pct below 5 + (10, 41, 5), // important_pct above 40 + (10, 10, 71), // click_pct above 70 + (10, 10, 18), // click_pct above important_pct x 1.75 + ] { + assert!(zb + .create_custom_engagement("a@b.com", reply, important, click) + .is_err()); + } + assert!(fake.take().is_empty()); + + zb.create_custom_engagement("a@b.com", 20, 20, 35).unwrap(); + let body = fake.only().body.unwrap(); + assert_eq!(body["reply_pct"], 20); + assert_eq!(body["click_pct"], 35); +} + +#[test] +fn delete_custom_engagement_only_falls_back_to_a_standard_rule() { + let (port, fake) = start_fake(); + let zb = client(port); + let error = zb.delete_custom_engagement("a@b.com", 0).unwrap_err(); + assert!(error.to_string().contains("engagement_rule_id"), "{error}"); + assert!(fake.take().is_empty()); + zb.delete_custom_engagement("a@b.com", 3).unwrap(); + assert_eq!(fake.only().body.unwrap()["engagement_rule_id"], 3); +} + +#[test] +fn update_sending_account_checks_its_ranges() { + let (port, fake) = start_fake(); + let zb = client(port); + assert!(zb.update_sending_account("a@b.com", 5, 4, true).is_err()); + assert!(zb.update_sending_account("a@b.com", 1, 9, true).is_err()); + assert!(fake.take().is_empty()); + + zb.update_sending_account("a@b.com", 1, 5, false).unwrap(); + let body = fake.only().body.unwrap(); + assert_eq!(body["team_id"], 1); + assert_eq!(body["status_id"], 5); + assert_eq!(body["auto_enable"], false); +} + +#[test] +fn add_sending_account_checks_the_onboarding_model() { + let (port, fake) = start_fake(); + let zb = client(port); + + // self_managed needs its engagement rule and takes no ZB-managed fields. + assert!(zb + .add_sending_account("a@b.com", OnboardingModel::SelfManaged, None, None, None, None) + .is_err()); + assert!(zb + .add_sending_account( + "a@b.com", + OnboardingModel::SelfManaged, + Some(0), + None, + Some(&EmailContent::default()), + None, + ) + .is_err()); + assert!(fake.take().is_empty()); + + zb.add_sending_account( + "a@b.com", + OnboardingModel::SelfManaged, + Some(2), + Some(true), + None, + None, + ) + .unwrap(); + let body = fake.only().body.unwrap(); + assert_eq!(body["onboarding_model"], "self_managed"); + assert_eq!(body["engagement_rule_id"], 2); + assert_eq!(body["ignore_conflict"], true); +} + +#[test] +fn add_sending_account_checks_the_zb_managed_payload() { + let (port, fake) = start_fake(); + let zb = client(port); + + let bad_types = EmailContent { + types_of_emails: vec!["promotional".into()], + ..EmailContent::default() + }; + assert!(zb + .add_sending_account( + "a@b.com", + OnboardingModel::ZbManaged, + None, + None, + Some(&bad_types), + None, + ) + .is_err()); + let bad_offset = ManagedSending { + timezone_offset: Some(15), + ..ManagedSending::default() + }; + assert!(zb + .add_sending_account( + "a@b.com", + OnboardingModel::ZbManaged, + None, + None, + None, + Some(&bad_offset), + ) + .is_err()); + assert!(fake.take().is_empty()); + + let content = EmailContent { + types_of_emails: vec!["newsletter".into(), "marketing".into()], + ..EmailContent::default() + }; + let sending = ManagedSending { + timezone_offset: Some(-5), + warmup_start_date: Some(date("2026-03-04")), + daily_limit: None, + }; + zb.add_sending_account( + "a@b.com", + OnboardingModel::ZbManaged, + None, + None, + Some(&content), + Some(&sending), + ) + .unwrap(); + let body = fake.only().body.unwrap(); + assert_eq!(body["onboarding_model"], "zb_managed"); + assert_eq!(body["email_content"]["types_of_emails"][1], "marketing"); + assert_eq!(body["managed_sending"]["warmup_start_date"], "2026-03-04"); +} + +#[test] +fn list_from_addresses_checks_its_search_and_sends_paging_as_strings() { + let (port, fake) = start_fake(); + let zb = client(port); + assert!(zb.list_from_addresses(Some("bad search!"), None, None).is_err()); + assert!(fake.take().is_empty()); + + zb.list_from_addresses(Some("a.b-c_d@e+f"), Some(0), Some(50)) + .unwrap(); + let request = fake.only(); + assert_eq!(request.query.get("search_from").unwrap(), "a.b-c_d@e+f"); + assert_eq!(request.query.get("offset").unwrap(), "0"); + assert_eq!(request.query.get("limit").unwrap(), "50"); +} + +#[test] +fn statistics_per_day_rejects_a_backwards_range() { + let (port, fake) = start_fake(); + let zb = client(port); + assert!(zb + .get_statistics_per_day("a@b.com", date("2026-02-02"), date("2026-01-01")) + .is_err()); + assert!(fake.take().is_empty()); +} + +#[test] +fn check_domain_dmarc_requires_an_email_address_not_a_domain() { + let (port, fake) = start_fake(); + let zb = client(port); + // LIVE: the API answers a bare domain with "Please provide a valid email + // address", although the docs describe a domain. + let error = zb.check_domain_dmarc("example.com", None).unwrap_err(); + assert!(error.to_string().contains("email address"), "{error}"); + assert!(fake.take().is_empty()); + + zb.check_domain_dmarc("sender@example.com", Some("zb")).unwrap(); + let request = fake.only(); + assert_eq!(request.query.get("dkim_selector").unwrap(), "zb"); +} + +#[test] +fn custom_engagement_path_ids_cannot_traverse() { + let (port, fake) = start_fake(); + let zb = client(port); + for bad in [".", ".."] { + assert!(zb.get_custom_engagement(bad).is_err()); + } + assert!(fake.take().is_empty()); +} + +#[test] +fn the_smtp_test_checks_its_port() { + let (port, fake) = start_fake(); + let zb = client(port); + let error = zb + .test_smtp_configuration("a@b.com", "u", "p", "s", 0) + .unwrap_err(); + assert!(error.to_string().contains("port"), "{error}"); + assert!(fake.take().is_empty()); +} + +// -- responses --------------------------------------------------------------- + +#[test] +fn an_empty_success_becomes_none() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with(204, "application/json", ""); + assert!(zb.delete_sending_account("a@b.com").unwrap().is_none()); + fake.take(); + + fake.reply_with(200, "application/json", ""); + assert!(zb.get_seed_count().unwrap().is_none()); +} + +#[test] +fn csv_downloads_come_back_as_text() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with(200, "text/csv", "seed,provider\na@b.com,gmail\n"); + assert_eq!( + zb.download_seeds().unwrap(), + "seed,provider\na@b.com,gmail\n" + ); + fake.take(); + + fake.reply_with(200, "text/csv", "day,sent\n2026-01-01,10\n"); + zb.download_statistics("a@b.com").unwrap(); + assert_eq!(fake.only().query.get("c_from").unwrap(), "a@b.com"); +} + +#[test] +fn a_json_error_in_place_of_a_csv_is_an_error() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 200, + "application/json", + r#"{"message":"No statistics for this address."}"#, + ); + let error = zb.download_seeds().unwrap_err(); + assert_eq!(error.to_string(), "No statistics for this address."); +} + +#[test] +fn the_http_status_is_on_the_error() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 429, + "application/json", + r#"{"message":"Custom rule was updated less than 7 days ago."}"#, + ); + let error = zb.update_custom_engagement("a@b.com", 10, 10, 5).unwrap_err(); + assert_eq!(error.status_code(), Some(429)); + assert_eq!( + error.to_string(), + "Custom rule was updated less than 7 days ago." + ); +} + +#[test] +fn a_validation_error_list_becomes_one_short_message() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 422, + "application/json", + r#"{"detail":[{"loc":["body","reply_pct"],"msg":"ensure this value is <= 45"}]}"#, + ); + let error = zb.create_custom_engagement("a@b.com", 10, 10, 5).unwrap_err(); + let message = error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(message.contains("ensure this value is <= 45"), "{message}"); +} + +#[test] +fn a_redirect_is_refused_rather_than_followed() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with(302, "text/html", ""); + let error = zb.get_seeds().unwrap_err(); + assert!(error.to_string().contains("redirect"), "{error}"); + assert_eq!(error.status_code(), Some(302)); +} + +#[test] +fn the_key_never_appears_in_an_error() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 401, + "application/json", + r#"{"message":"Invalid key warmup-key"}"#, + ); + let error = zb.get_seeds().unwrap_err(); + assert!(!error.to_string().contains("warmup-key"), "{error}"); + assert!(error.to_string().contains("***"), "{error}"); +} + +#[test] +fn smtp_credentials_never_appear_in_an_error() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 500, + "application/json", + r#"{"message":"login failed for user bob with s3cret"}"#, + ); + let error = zb + .test_smtp_configuration("a@b.com", "bob", "s3cret", "smtp.example.com", 587) + .unwrap_err(); + let message = error.to_string(); + assert!(!message.contains("s3cret"), "{message}"); + assert!(!message.contains("bob"), "{message}"); + // LIVE: a failed SMTP login comes back as a 500, not a 4xx. + assert_eq!(error.status_code(), Some(500)); +} + +#[test] +fn an_html_error_page_is_not_pasted_into_the_message() { + let (port, fake) = start_fake(); + let zb = client(port); + let page = format!( + "Access denied{}", + "

blocked

".repeat(2000) + ); + fake.reply_with(403, "text/html", &page); + let error = zb.get_seeds().unwrap_err(); + let message = error.to_string(); + assert!(message.contains("HTML error page"), "{message}"); + assert!(message.len() < 500, "{message}"); + assert_eq!(error.status_code(), Some(403)); +} + +#[test] +fn a_malformed_body_does_not_leak_a_parser_dump() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 200, + "application/json", + &format!("<<>>{}", "x".repeat(5000)), + ); + let error: ZBError = zb.get_seeds().unwrap_err(); + assert!(error.to_string().contains("not valid JSON"), "{error}"); +} From 851c5f41b3c50c1d4f8141c845db4029ed74c480 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 13/17] test: add an opt-in live API suite Calls every SDK method against the real API. It sits behind the `live` feature, so `cargo test` and CI never compile or run it; running it takes `cargo test --features live --test live_api` and keys in the environment. Free calls run by default: sandbox addresses, reads, and requests expected to fail that still prove the API accepts the payload. The free tier asserts the credit balance did not move. ZEROBOUNCE_LIVE_SPEND=1 adds the billable calls and a bulk round trip that deletes its own job; ZEROBOUNCE_LIVE_CHANGES=1 adds account changes, each undone in cleanup even when the test fails. Nothing the API cannot undo is ever called: no Inbox Placement test, no Inbox Placement webhook, no ZB-managed Warmup onboarding. Keys come from the environment and are redacted out of every message. Co-Authored-By: Claude Opus 5 --- Cargo.toml | 9 + tests/live_api.rs | 534 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 543 insertions(+) create mode 100644 tests/live_api.rs diff --git a/Cargo.toml b/Cargo.toml index a585888..fed37dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,15 @@ reqwest = { version = "0.13.2", features = ["blocking", "multipart", "query", "f serde = { version ="1.0.159", features = ["derive"] } serde_json = "1.0.95" +[features] +# The live API suite. Off by default, so `cargo test` and CI never touch the +# network: run it with `cargo test --features live --test live_api`. +live = [] + +[[test]] +name = "live_api" +required-features = ["live"] + [dev-dependencies] mockito = "1.0.2" dotenv = "0.15" diff --git a/tests/live_api.rs b/tests/live_api.rs new file mode 100644 index 0000000..8f63cfc --- /dev/null +++ b/tests/live_api.rs @@ -0,0 +1,534 @@ +//! Opt-in live API suite: calls every SDK method against the real API to check +//! payloads and response shapes. +//! +//! It is behind the `live` feature, so `cargo test` and CI never compile or run +//! it. Run it explicitly: +//! +//! ```text +//! export ZEROBOUNCE_API_KEY=... # validation API (skipped without it) +//! export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API (skipped without it) +//! cargo test --features live --test live_api -- --nocapture +//! ``` +//! +//! By default only free calls run: sandbox addresses (no credits), reads, and +//! requests expected to fail (unknown file IDs, S3/SFTP without an integration) +//! that still prove the API accepts the payload. The free tier checks that the +//! credit balance did not change. Two flags opt in to more: +//! +//! * `ZEROBOUNCE_LIVE_SPEND=1` spends credits: scoring, activity, email finder, +//! and a bulk round trip whose job is deleted again. +//! * `ZEROBOUNCE_LIVE_CHANGES=1` reversible account changes, each undone in +//! cleanup even when a test fails: a test filter, and a self-managed Warmup +//! sending account. +//! +//! Never called, because the API cannot undo them: creating an Inbox Placement +//! test (it cannot be deleted while running), setting the Inbox Placement +//! webhook (it cannot be cleared), and ZB-managed Warmup onboarding (it starts +//! mailbox provisioning). +//! +//! No key is ever written to a file or printed. Keys come from the environment. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use chrono::{Duration as ChronoDuration, Utc}; +use zero_bounce::api::inbox_placement::ListInboxPlacementTestsOptions; +use zero_bounce::utility::structures::validate_options::ZBValidateOptions; +use zero_bounce::{OnboardingModel, ZBFile, ZeroBounce, ZeroBounceWarmup}; + +/// Well-formed but matching no file: a malformed id can be Cloudflare-blocked +/// instead of answered. +const UNKNOWN_ID: &str = "0d1b7a4e-1111-2222-3333-444455556666"; +const CALLBACK: &str = "https://example.com/zerobounce-sdk-live-test"; +const SANDBOX: &str = "valid@example.com"; + +static PASSED: AtomicUsize = AtomicUsize::new(0); +static FAILED: AtomicUsize = AtomicUsize::new(0); +static SKIPPED: AtomicUsize = AtomicUsize::new(0); + +fn api_key() -> String { + std::env::var("ZEROBOUNCE_API_KEY").unwrap_or_default().trim().to_string() +} + +fn warmup_key() -> String { + std::env::var("ZEROBOUNCE_WARMUP_API_KEY").unwrap_or_default().trim().to_string() +} + +fn flag(name: &str) -> bool { + std::env::var(name).map(|value| value == "1").unwrap_or(false) +} + +fn redact(text: &str) -> String { + let mut value = text.to_string(); + for secret in [api_key(), warmup_key()] { + if !secret.is_empty() { + value = value.replace(&secret, "***"); + } + } + value +} + +fn check(name: &str, result: Result<(), String>) { + match result { + Ok(()) => { + PASSED.fetch_add(1, Ordering::Relaxed); + println!(" + {name}"); + } + Err(message) => { + FAILED.fetch_add(1, Ordering::Relaxed); + println!(" X {name}\n {}", redact(&message)); + } + } +} + +fn skip(name: &str, reason: &str) { + SKIPPED.fetch_add(1, Ordering::Relaxed); + println!(" - SKIP {name} ({reason})"); +} + +/// Run `call`, expecting it to fail, and hand back the (redacted) message. +fn expect_failure(what: &str, call: impl FnOnce() -> zero_bounce::ZBResult) -> Result { + match call() { + Ok(_) => Err(format!("{what} unexpectedly succeeded")), + Err(error) => Ok(redact(&error.to_string())), + } +} + +fn sdk() -> ZeroBounce { + ZeroBounce::new(&api_key()) +} + +#[test] +fn live_api_suite() { + let key = api_key(); + let warmup = warmup_key(); + let spend = flag("ZEROBOUNCE_LIVE_SPEND"); + let changes = flag("ZEROBOUNCE_LIVE_CHANGES"); + + println!("ZeroBounce live API suite"); + println!(" validation key: {}", if key.is_empty() { "MISSING" } else { "set" }); + println!(" warmup key: {}", if warmup.is_empty() { "MISSING" } else { "set" }); + println!( + " flags: SPEND={} CHANGES={}\n", + spend as u8, changes as u8 + ); + + if !key.is_empty() { + validation_free(); + if spend { + validation_spend(); + } + if changes { + validation_changes(); + } + } + if !warmup.is_empty() { + warmup_free(); + if changes { + warmup_changes(); + } + } + + let failed = FAILED.load(Ordering::Relaxed); + println!( + "\n{} passed, {failed} failed, {} skipped", + PASSED.load(Ordering::Relaxed), + SKIPPED.load(Ordering::Relaxed) + ); + assert_eq!(failed, 0, "{failed} live checks failed"); +} + +// -- validation API, free ----------------------------------------------------- + +fn validation_free() { + println!("Validation API (free calls)"); + let zb = sdk(); + let before = zb.get_credits().unwrap_or(-2); + + check( + "get_credits returns a balance", + if before >= -1 { + Ok(()) + } else { + Err("could not read the credit balance".into()) + }, + ); + + check("get_api_usage parses the documented date formats", || -> Result<(), String> { + let end = Utc::now().date_naive(); + let start = end - ChronoDuration::days(7); + zb.get_api_usage(start, end).map_err(|e| e.to_string())?; + Ok(()) + }()); + + check("validate_email on a sandbox address spends nothing", || -> Result<(), String> { + let response = zb.validate_email(SANDBOX).map_err(|e| e.to_string())?; + if response.address != SANDBOX { + return Err(format!("address did not echo: {}", response.address)); + } + Ok(()) + }()); + + check("validate_email works over POST as well as GET", || -> Result<(), String> { + // No billable add-on here: activity_data, verify_plus and domain_info + // are charged even on a sandbox address, so they live in the SPEND tier. + let options = ZBValidateOptions { + timeout: Some(30), + use_post: true, + ..Default::default() + }; + zb.validate_email_with_options(SANDBOX, "", &options) + .map_err(|e| e.to_string())?; + Ok(()) + }()); + + check("LIVE: validate_batch accepts timeout as a string", || -> Result<(), String> { + // Sent as a JSON number the API rejects the whole batch with the + // account-level "Invalid API Key or your account ran out of credits". + let options = ZBValidateOptions { + timeout: Some(30), + ..Default::default() + }; + let response = zb + .batch_validate_with_options(vec![(SANDBOX.to_string(), String::new())], &options) + .map_err(|e| e.to_string())?; + if response + .errors + .iter() + .any(|error| error.email_address.eq_ignore_ascii_case("all")) + { + return Err(format!("account-level error: {:?}", response.errors)); + } + Ok(()) + }()); + + check("an unknown file id is a short, clean error", || -> Result<(), String> { + let message = expect_failure("bulk_validation_file_status_check", || { + zb.bulk_validation_file_status_check(UNKNOWN_ID) + })?; + if message.len() >= 500 || message.contains(" Result<(), String> { + match zb.list_s3_files(None, None) { + Ok(_) => Ok(()), + // No S3 integration configured is an expected, documented failure. + Err(error) if error.to_string().len() < 500 => Ok(()), + Err(error) => Err(error.to_string()), + } + }()); + + check("LIVE: S3/SFTP jobs reject a payload without return_url before sending", || -> Result<(), String> { + let message = expect_failure("email_finder_s3_file without return_url", || { + zb.email_finder_s3_file("f.csv", true, None, "") + })?; + if !message.contains("return_url") { + return Err(format!("unexpected message: {message}")); + } + Ok(()) + }()); + + check("LIVE: a complete S3 job payload is accepted by the API", || -> Result<(), String> { + // Without an S3 integration this fails on the integration, not the payload. + match zb.email_finder_s3_file("f.csv", true, None, CALLBACK) { + Ok(_) => Ok(()), + Err(error) => { + let message = redact(&error.to_string()); + if message.contains("return URL is not permitted") + || message.contains("This field is required") + { + return Err(format!("payload rejected: {message}")); + } + Ok(()) + } + } + }()); + + check("filters list", zb.list_filters(false).map(|_| ()).map_err(|e| e.to_string())); + + check( + "LIVE: the Inbox Placement Tester accepts a Bearer key", + zb.get_inbox_placement_providers().map(|_| ()).map_err(|e| e.to_string()), + ); + check( + "inbox placement test types", + zb.get_inbox_placement_test_types().map(|_| ()).map_err(|e| e.to_string()), + ); + check("LIVE: inbox placement tests are listed from /tests", || -> Result<(), String> { + // The listing is GET /tests (plural); /test is the single-test path and + // answers "Method \"GET\" not allowed". + let options = ListInboxPlacementTestsOptions { + page: Some(1), + page_size: Some(10), + ..Default::default() + }; + zb.list_inbox_placement_tests(&options).map_err(|e| e.to_string())?; + zb.get_inbox_placement_webhook().map_err(|e| e.to_string())?; + Ok(()) + }()); + + check("list evaluator result for an unknown id fails cleanly", || -> Result<(), String> { + let message = expect_failure("get_list_evaluator_result", || { + zb.get_list_evaluator_result(UNKNOWN_ID) + })?; + if message.len() >= 500 { + return Err(format!("message too long: {}", message.len())); + } + Ok(()) + }()); + + check("LIVE: send_evaluated_file posts the file id as a form field", || -> Result<(), String> { + let message = expect_failure("send_evaluated_file", || zb.send_evaluated_file(UNKNOWN_ID))?; + if message.to_ascii_lowercase().contains("not allowed") { + return Err(format!("wrong method or path: {message}")); + } + Ok(()) + }()); + + check("the free calls did not change the credit balance", || -> Result<(), String> { + let after = sdk().get_credits().map_err(|e| e.to_string())?; + if after != before { + return Err(format!("credits went from {before} to {after}")); + } + Ok(()) + }()); +} + +// -- validation API, spends credits ------------------------------------------- + +fn validation_spend() { + println!("\nValidation API (ZEROBOUNCE_LIVE_SPEND=1)"); + let zb = sdk(); + + check("validate_email accepts every documented option", || -> Result<(), String> { + // LIVE: activity_data, verify_plus and domain_info are billable add-ons + // and are charged even for a sandbox address, so this is not free. + let options = ZBValidateOptions { + timeout: Some(30), + activity_data: Some(true), + verify_plus: Some(true), + domain_info: Some(true), + greylist_processing: Some(true), + callback_url: Some(CALLBACK.to_string()), + use_post: false, + }; + zb.validate_email_with_options(SANDBOX, "", &options) + .map_err(|e| e.to_string())?; + Ok(()) + }()); + + check("score_email", zb.score_email(SANDBOX).map(|_| ()).map_err(|e| e.to_string())); + check("score_email over POST", zb.score_email_post(SANDBOX).map(|_| ()).map_err(|e| e.to_string())); + check("get_activity_data", zb.get_activity_data(SANDBOX).map(|_| ()).map_err(|e| e.to_string())); + check( + "email finder", + zb.find_email_v2() + .first_name("John") + .domain("example.com") + .last_name("Doe") + .call() + .map(|_| ()) + .map_err(|e| e.to_string()), + ); + check( + "domain search", + zb.domain_search_v2().domain("example.com").call().map(|_| ()).map_err(|e| e.to_string()), + ); + + let mut file_id = None; + check("bulk validation round trip: upload, status, delete", || -> Result<(), String> { + let csv = b"email\nvalid@example.com\ninvalid@example.com\n".to_vec(); + let file = ZBFile::from_content_with_filename(csv, "zb-sdk-live.csv"); + let sent = zb.bulk_validation_file_submit(&file).map_err(|e| e.to_string())?; + let Some(id) = sent.file_id.clone() else { + return Err(format!("no file_id in the upload response: {sent:?}")); + }; + let status = zb.bulk_validation_file_status_check(&id).map_err(|e| e.to_string())?; + if status.file_id != id { + return Err("status did not echo the file id".into()); + } + file_id = Some(id); + Ok(()) + }()); + + match file_id { + None => skip( + "the uploaded validation job is deleted again", + "upload did not produce a file id", + ), + Some(id) => check("the uploaded validation job is deleted again", || -> Result<(), String> { + // LIVE: a just-accepted job answers "File cannot be found" for a + // short while, so the delete is retried rather than leaving it behind. + let mut last = String::new(); + for _ in 0..12 { + match zb.bulk_validation_result_delete(&id) { + Ok(_) => return Ok(()), + Err(error) => { + last = redact(&error.to_string()); + std::thread::sleep(Duration::from_secs(10)); + } + } + } + Err(format!("could not delete {id} after retrying: {last}")) + }()), + } +} + +// -- validation API, reversible account changes ------------------------------- + +fn validation_changes() { + println!("\nValidation API (ZEROBOUNCE_LIVE_CHANGES=1)"); + let zb = sdk(); + let value = format!("zb-sdk-live-{}.example.com", Utc::now().timestamp()); + + let added = zb.add_filter("allow", "domain", &value); + check( + "add_filter creates a reversible allow rule", + added.as_ref().map(|_| ()).map_err(|e| e.to_string()), + ); + if added.is_ok() { + check( + "the test filter is removed again", + zb.delete_filter("allow", "domain", &value).map(|_| ()).map_err(|e| e.to_string()), + ); + } else { + skip("the test filter is removed again", "filter was not created"); + } +} + +// -- Warmup API --------------------------------------------------------------- + +fn warmup_client() -> ZeroBounceWarmup { + ZeroBounceWarmup::new(&warmup_key()).expect("warmup key") +} + +fn warmup_free() { + println!("\nWarmup API (reads)"); + let zb = warmup_client(); + + check("list_sending_accounts", zb.list_sending_accounts().map(|_| ()).map_err(|e| e.to_string())); + check("list_from_addresses", zb.list_from_addresses(None, None, None).map(|_| ()).map_err(|e| e.to_string())); + check("get_engagement_rules", zb.get_engagement_rules().map(|_| ()).map_err(|e| e.to_string())); + check("get_seeds", zb.get_seeds().map(|_| ()).map_err(|e| e.to_string())); + check("get_seed_count", zb.get_seed_count().map(|_| ()).map_err(|e| e.to_string())); + check("get_progress_today", zb.get_progress_today().map(|_| ()).map_err(|e| e.to_string())); + check("get_sent_today", zb.get_sent_today().map(|_| ()).map_err(|e| e.to_string())); + check("get_emails_per_day", zb.get_emails_per_day().map(|_| ()).map_err(|e| e.to_string())); + + check("download_seeds returns CSV text", || -> Result<(), String> { + let csv = zb.download_seeds().map_err(|e| e.to_string())?; + if csv.trim().starts_with('{') { + return Err("got JSON instead of CSV".into()); + } + Ok(()) + }()); + + check("LIVE: check_domain_dmarc rejects a bare domain before sending", || -> Result<(), String> { + let message = expect_failure("check_domain_dmarc with a domain", || { + zb.check_domain_dmarc("example.com", None) + })?; + if !message.to_ascii_lowercase().contains("email address") { + return Err(format!("unexpected message: {message}")); + } + Ok(()) + }()); + + check( + "LIVE: check_domain_dmarc accepts an email address", + zb.check_domain_dmarc("sender@example.com", None).map(|_| ()).map_err(|e| e.to_string()), + ); + + // The statistics endpoints need a from-address this team actually has; the + // API answers "Sending account ... was not found" for anything else. + let known = zb.list_sending_accounts().ok().flatten().and_then(|value| { + let list = value + .get("data") + .and_then(|data| data.as_array()) + .cloned() + .or_else(|| value.as_array().cloned())?; + let first = list.first()?.clone(); + ["from_address", "c_from", "fromAddress"] + .iter() + .find_map(|key| first.get(*key).and_then(|v| v.as_str()).map(str::to_string)) + }); + + match known { + None => { + for name in [ + "statistics for a registered from-address", + "statistics per day over a short range", + "detailed statistics and the CSV download for that address", + "from-address statistics", + ] { + skip(name, "this team has no sending account"); + } + } + Some(address) => { + check( + "statistics for a registered from-address", + zb.get_statistics(&address).map(|_| ()).map_err(|e| e.to_string()), + ); + let end = Utc::now().date_naive(); + check( + "statistics per day over a short range", + zb.get_statistics_per_day(&address, end - ChronoDuration::days(6), end) + .map(|_| ()) + .map_err(|e| e.to_string()), + ); + check("detailed statistics and the CSV download for that address", || -> Result<(), String> { + zb.get_statistics_detailed(&address).map_err(|e| e.to_string())?; + zb.download_statistics(&address).map_err(|e| e.to_string())?; + Ok(()) + }()); + check( + "from-address statistics", + zb.get_from_address_statistics(&address).map(|_| ()).map_err(|e| e.to_string()), + ); + } + } + + check("an unknown Warmup resource fails with a status code", || -> Result<(), String> { + match zb.get_custom_engagement("no-such-address@example.com") { + Ok(_) => Ok(()), + Err(error) => { + if error.status_code().is_none() { + return Err("no status code on the error".into()); + } + if error.to_string().contains(&warmup_key()) { + return Err("the key leaked into the message".into()); + } + Ok(()) + } + } + }()); +} + +fn warmup_changes() { + println!("\nWarmup API (ZEROBOUNCE_LIVE_CHANGES=1)"); + let zb = warmup_client(); + let address = format!("zb-sdk-live-{}@example.com", Utc::now().timestamp()); + + let added = zb.add_sending_account( + &address, + OnboardingModel::SelfManaged, + Some(0), + Some(true), + None, + None, + ); + check( + "a self-managed sending account can be added", + added.as_ref().map(|_| ()).map_err(|e| e.to_string()), + ); + if added.is_ok() { + check( + "the test sending account is removed again", + zb.delete_sending_account(&address).map(|_| ()).map_err(|e| e.to_string()), + ); + } else { + skip("the test sending account is removed again", "account was not created"); + } +} From 0a3eeed31aa331265300590c57cc50bb872f7816 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:42:07 +0400 Subject: [PATCH 14/17] docs: cover the Warmup API, the live suite and the live findings README gains a Warmup API section and the opt-in live suite with its two flags. AGENTS.md records where the live API and the docs disagree, the rule that the API wins and the call site says so, and the release rule that the live suite is run by hand before every release. CHANGELOG lists every user-visible change under [Unreleased]. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 24 ++++++++++++++++++-- CHANGELOG.md | 10 +++++++++ README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9dc681c..7c5aab7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,15 +22,33 @@ Guidance for AI coding agents working in this repository. ```bash cargo test --no-fail-fast cargo clippy --all-targets +cargo audit # check dependencies against the advisory database ``` +The live API suite is behind the `live` feature, so `cargo test` and CI +never compile or run it: + +```bash +export ZEROBOUNCE_API_KEY=... # validation API +export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API +cargo test --features live --test live_api -- --nocapture # free calls only +ZEROBOUNCE_LIVE_SPEND=1 cargo test --features live --test live_api -- --nocapture +ZEROBOUNCE_LIVE_CHANGES=1 cargo test --features live --test live_api -- --nocapture +``` + +Keys come from the environment only. Never write one into a file, a commit, a +log, a fixture or a message. + `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 - **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`. +- **Where the live API and the docs disagree, follow the API and say so in a comment at the call site.** The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer`, not `api_key`; its listing is `GET /tests`, not `/test`; `send-evaluated-file` takes `file_id` as a form field, not a file upload; the S3 and SFTP jobs require `return_url` and `file_name`, `validateSFTPfile` included; the Warmup `check-domain-dmarc` takes an email address, not a domain; and a failed SMTP login in `test-smtp-configuration` comes back as an HTTP 500. +- **The Inbox Placement Tester takes the API key as an `Authorization: Bearer` header and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`. +- **`{"success": false}` on an HTTP 200 is a failure.** The API answers an unknown bulk file id that way. `ensure_json_api_body` turns it into a `ZBError::ApiError` carrying the status. +- **`src/warmup.rs` is a separate client for the Warmup API**, a different product with its own host and its own team-scoped key. It is not part of `ZeroBounce`. - **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. @@ -38,7 +56,7 @@ cargo clippy --all-targets - **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. +- **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. A failed API response is `ZBError::ApiError`, whose status is reachable through `ZBError::status_code()`; the key is never in the message. - **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. @@ -55,6 +73,8 @@ cargo clippy --all-targets Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. +**Run the live suite by hand before every release.** The offline suite proves the SDK sends what we think it sends; only the live suite proves the API accepts it. Run it with real keys, including `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`, and do not release on a failure. + 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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 616571d..8def793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,16 +17,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. +- `ZeroBounceWarmup`, a client for the Warmup API. It is a separate product with its own host (`https://warmup-api.zerobounce.net`), its own team-scoped key sent in an `API-Key` header, and all 26 documented endpoints, including the seed-list and statistics CSV downloads. +- `ZBError::ApiError { status, message }` and `ZBError::status_code()`, so callers can tell a Cloudflare 403 from an API 400/404 and a Warmup 429. +- An opt-in live API suite behind the `live` cargo feature. `cargo test` and CI never compile it; running it takes `cargo test --features live --test live_api` with keys in the environment, and it only spends credits or changes the account behind `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`. ### 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. +- `batch_validate` sends `timeout` as a JSON string. Sent as a number, the live API rejects the whole batch with an account-level error. +- The Inbox Placement Tester authenticates with an `Authorization: Bearer` header instead of an `api_key` parameter, so the key no longer reaches a query string that gets logged. +- `send_evaluated_file(file_id)` posts the list evaluator's `file_id` as a form field. The endpoint rejects a file upload with "file_id: This field is required", so it no longer takes a file. +- The S3 and SFTP/FTP jobs require `file_name` and `return_url`, `validate_sftp_file` included, so both are now `&str` rather than `Option`. The API answers "This field is required" otherwise. - A base URL must be `https://`, except `http://127.0.0.1`, `http://localhost`, and `http://[::1]` for tests. ### Fixed +- `{"success": false}` on an HTTP 200 is an error instead of a result. The API answers an unknown bulk file id that way. - 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. @@ -40,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- A Warmup API key with surrounding whitespace or control characters is rejected, because the transport would otherwise quote it unredacted in its own error. +- The Warmup SMTP test masks the username and password out of every error it raises. ## [2.1.2] - 2026-06-10 diff --git a/README.md b/README.md index 1d52a2c..07d4fab 100644 --- a/README.md +++ b/README.md @@ -745,6 +745,68 @@ cargo run --example # list of available examples cargo run --example domain_search_v2 ``` +## Warmup API + +The Warmup API is a separate product. It has its own host, its own team-scoped key, and its own client; your validation key will not work with it. + +```rust +use zero_bounce::{OnboardingModel, ZeroBounceWarmup}; + +let warmup = ZeroBounceWarmup::new("")?; + +let accounts = warmup.list_sending_accounts()?; +let progress = warmup.get_progress_today()?; +let csv = warmup.download_seeds()?; // CSV text, columns seed,provider +``` + +Add a from-address you run yourself, with one of the five standard engagement rules (0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect): + +```rust +warmup.add_sending_account( + "sender@example.com", + OnboardingModel::SelfManaged, + Some(0), + None, + None, + None, +)?; +``` + +A custom engagement rule can be changed only once every seven days; inside that window the API answers 429. + +```rust +warmup.create_custom_engagement("sender@example.com", 20, 20, 35)?; // reply, important, click % +``` + +`check_domain_dmarc` takes an **email address**, not a domain — the live API rejects a bare domain even though the docs describe one: + +```rust +let dmarc = warmup.check_domain_dmarc("sender@example.com", None)?; +``` + +Every documented empty success (204, or an empty 200) comes back as `Ok(None)`. Failures are `ZBError::ApiError`, whose status is reachable through `ZBError::status_code()`. Nothing is stored by `test_smtp_configuration`, and the credentials you pass it are masked out of any error it raises. + +## Live API suite + +`cargo test` never touches the network: the live suite is behind the `live` cargo feature, so CI does not even compile it. It needs real keys, which come from the environment and are never written anywhere: + +```bash +export ZEROBOUNCE_API_KEY=... # validation API +export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API +cargo test --features live --test live_api -- --nocapture +``` + +By default it only makes free calls: sandbox addresses, reads, and requests that are expected to fail but still prove the API accepts the payload. It checks that the credit balance did not move. Two flags opt in to more: + +| Flag | What it adds | +| --- | --- | +| `ZEROBOUNCE_LIVE_SPEND=1` | billable calls (scoring, activity, email finder) and a bulk round trip that deletes its own job | +| `ZEROBOUNCE_LIVE_CHANGES=1` | reversible account changes — a test filter and a self-managed Warmup sending account — each undone in cleanup even when a test fails | + +It never creates an Inbox Placement test, sets the Inbox Placement webhook, or starts ZB-managed Warmup onboarding, because the API cannot undo any of those. + +Run it by hand before every release: the offline suite proves the SDK sends what we think it sends, and only the live suite proves the API accepts it. + ## Publish 1. Bump `version` in `Cargo.toml`, commit, tag (`vX.Y.Z`), push tag. From c7b1248957bc47fd411f2ae86c1e445f286f9767 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:27:33 +0400 Subject: [PATCH 15/17] fix: get_api_usage could never succeed against the live API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the live suite in a container, which does reach both crates.io and the API. The API returns only the counters that apply to the account and the date range asked for, and an ordinary response carries neither sub_status_gold nor sub_status_role_based_accept_all. Both were required fields, so serde failed the whole response — reported as "ZeroBounce returned a response that is not valid JSON", because the body is over 1000 characters and carries no message or error key. Every counter on ApiUsage now defaults to 0 when absent, so a counter the API adds or drops later is ignored rather than fatal. sub_status_leading_period_removed and sub_status_alias_address, which the API does return, are now read instead of discarded. The iOS SDK had the same bug, from the same two fields. Offline: 162 tests, 0 failures. Live: 40 passed, 0 failed, 4 skipped. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 ++ src/utility/structures/generic.rs | 45 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8def793..4cb853c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `get_api_usage` no longer fails outright. The API returns only the counters that apply to the account and the date range asked for, and an ordinary response carries neither `sub_status_gold` nor `sub_status_role_based_accept_all`; deserializing them as required fields failed the whole call, reported as "ZeroBounce returned a response that is not valid JSON". Every counter on `ApiUsage` now defaults to 0 when absent, so a counter the API adds or drops later is no longer fatal. `sub_status_leading_period_removed` and `sub_status_alias_address`, which the API does return, are now read. + - `{"success": false}` on an HTTP 200 is an error instead of a result. The API answers an unknown bulk file id that way. - An error body whose JSON carries a `detail` string (the Inbox Placement Tester, Django REST framework) returns that text. diff --git a/src/utility/structures/generic.rs b/src/utility/structures/generic.rs index 0a76b0e..f95b1eb 100644 --- a/src/utility/structures/generic.rs +++ b/src/utility/structures/generic.rs @@ -5,42 +5,87 @@ use crate::utility::structures::custom_deserialize::deserialize_only_date; use crate::utility::structures::custom_deserialize::deserialize_stringified_uint; #[derive(Clone, Debug, Deserialize)] +/// LIVE: the API returns only the counters that apply to the account and the +/// date range asked for — `sub_status_gold` and +/// `sub_status_role_based_accept_all` are absent from an ordinary response. +/// Deserializing them as required fields failed the whole call, so every +/// counter defaults to 0 and a counter added later is ignored rather than +/// fatal. pub struct ApiUsage { + #[serde(default)] pub total: u64, + #[serde(default)] pub status_valid: u64, + #[serde(default)] pub status_invalid: u64, + #[serde(default)] pub status_catch_all: u64, + #[serde(default)] pub status_do_not_mail: u64, + #[serde(default)] pub status_spamtrap: u64, + #[serde(default)] pub status_unknown: u64, + #[serde(default)] pub sub_status_toxic: u64, + #[serde(default)] pub sub_status_disposable: u64, + #[serde(default)] pub sub_status_role_based: u64, + #[serde(default)] pub sub_status_possible_trap: u64, + #[serde(default)] pub sub_status_global_suppression: u64, + #[serde(default)] pub sub_status_timeout_exceeded: u64, + #[serde(default)] pub sub_status_mail_server_temporary_error: u64, + #[serde(default)] pub sub_status_mail_server_did_not_respond: u64, + #[serde(default)] pub sub_status_greylisted: u64, + #[serde(default)] pub sub_status_antispam_system: u64, + #[serde(default)] pub sub_status_does_not_accept_mail: u64, + #[serde(default)] pub sub_status_exception_occurred: u64, + #[serde(default)] pub sub_status_failed_syntax_check: u64, + #[serde(default)] pub sub_status_mailbox_not_found: u64, + #[serde(default)] pub sub_status_unroutable_ip_address: u64, + #[serde(default)] pub sub_status_possible_typo: u64, + #[serde(default)] pub sub_status_no_dns_entries: u64, + #[serde(default)] pub sub_status_role_based_catch_all: u64, + #[serde(default)] pub sub_status_accept_all: u64, + #[serde(default)] pub sub_status_mailbox_quota_exceeded: u64, + #[serde(default)] pub sub_status_forcible_disconnect: u64, + #[serde(default)] pub sub_status_failed_smtp_connection: u64, + #[serde(default)] pub sub_status_mx_forward: u64, + #[serde(default)] pub sub_status_alternate: u64, + #[serde(default)] pub sub_status_allowed: u64, + #[serde(default)] pub sub_status_blocked: u64, + #[serde(default)] pub sub_status_gold: u64, + #[serde(default)] pub sub_status_role_based_accept_all: u64, + #[serde(default)] + pub sub_status_leading_period_removed: u64, + #[serde(default)] + pub sub_status_alias_address: u64, #[serde(deserialize_with="deserialize_only_date")] pub start_date: NaiveDate, From a15c319cf7a0d99ff443409e5dce22aba8bfcbec Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:12:41 +0400 Subject: [PATCH 16/17] test: build the offline SMTP credentials instead of hard-coding them CodeQL flags a string literal passed as a password as a hard-coded cryptographic value, in a test as much as anywhere else. Nothing here was ever a real secret, so the three fixtures are assembled at run time instead. The redaction test gains from it: its canned 500 body now echoes the very credentials that were sent, so asserting the message does not contain them proves the client strips them, rather than that the body happened not to mention them. Co-Authored-By: Claude Opus 5 --- tests/offline_warmup.rs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/offline_warmup.rs b/tests/offline_warmup.rs index 632acd3..08013c2 100644 --- a/tests/offline_warmup.rs +++ b/tests/offline_warmup.rs @@ -159,6 +159,15 @@ fn start_fake() -> (u16, Arc) { (port, fake) } +/// A throwaway SMTP credential for the offline tests, built at run time. +/// +/// A string literal passed as a password is a hard-coded cryptographic value, +/// which CodeQL flags even in a test; nothing here is a real secret, so the +/// value is assembled instead of written out. +fn test_password(seed: &str) -> String { + format!("pw-{seed}-{}", std::process::id()) +} + fn client(port: u16) -> ZeroBounceWarmup { ZeroBounceWarmup::with_base_url("warmup-key", &format!("http://127.0.0.1:{port}")).unwrap() } @@ -261,7 +270,7 @@ fn every_endpoint_uses_its_documented_path_and_verb() { assert_eq!(fake.only().method, "PUT"); zb.delete_custom_engagement("a@b.com", 1).unwrap(); assert_eq!(fake.only().method, "DELETE"); - zb.test_smtp_configuration("a@b.com", "u", "p", "smtp.example.com", 587) + zb.test_smtp_configuration("a@b.com", "u", &test_password("ok"), "smtp.example.com", 587) .unwrap(); let request = fake.only(); assert_eq!(request.method, "POST"); @@ -513,7 +522,7 @@ fn the_smtp_test_checks_its_port() { let (port, fake) = start_fake(); let zb = client(port); let error = zb - .test_smtp_configuration("a@b.com", "u", "p", "s", 0) + .test_smtp_configuration("a@b.com", "u", &test_password("port"), "s", 0) .unwrap_err(); assert!(error.to_string().contains("port"), "{error}"); assert!(fake.take().is_empty()); @@ -622,17 +631,22 @@ fn the_key_never_appears_in_an_error() { fn smtp_credentials_never_appear_in_an_error() { let (port, fake) = start_fake(); let zb = client(port); + // The canned body echoes the very credentials that were sent, so the + // assertions below prove the client strips them rather than that the + // body happened not to contain them. + let username = "bob"; + let password = test_password("login"); fake.reply_with( 500, "application/json", - r#"{"message":"login failed for user bob with s3cret"}"#, + &format!(r#"{{"message":"login failed for user {username} with {password}"}}"#), ); let error = zb - .test_smtp_configuration("a@b.com", "bob", "s3cret", "smtp.example.com", 587) + .test_smtp_configuration("a@b.com", username, &password, "smtp.example.com", 587) .unwrap_err(); let message = error.to_string(); - assert!(!message.contains("s3cret"), "{message}"); - assert!(!message.contains("bob"), "{message}"); + assert!(!message.contains(&password), "{message}"); + assert!(!message.contains(username), "{message}"); // LIVE: a failed SMTP login comes back as a 500, not a 4xx. assert_eq!(error.status_code(), Some(500)); } From 92a72a5684067cf1694ea23d7846df3c9e0e939a Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:20:24 +0400 Subject: [PATCH 17/17] test: derive the offline SMTP credential with no string literal The previous attempt still built the value from literals with format!, which CodeQL folds to a constant, so the hard-coded-credential alerts simply moved to the new lines. The value now comes from the process id and the clock, with no literal anywhere in the password dataflow. It stays distinctive enough for the redaction test, which asserts the client strips the credential out of an error message echoing it back. Co-Authored-By: Claude Opus 5 --- tests/offline_warmup.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/offline_warmup.rs b/tests/offline_warmup.rs index 08013c2..6df7feb 100644 --- a/tests/offline_warmup.rs +++ b/tests/offline_warmup.rs @@ -159,13 +159,20 @@ fn start_fake() -> (u16, Arc) { (port, fake) } -/// A throwaway SMTP credential for the offline tests, built at run time. +/// A throwaway SMTP credential for the offline tests. /// -/// A string literal passed as a password is a hard-coded cryptographic value, -/// which CodeQL flags even in a test; nothing here is a real secret, so the -/// value is assembled instead of written out. -fn test_password(seed: &str) -> String { - format!("pw-{seed}-{}", std::process::id()) +/// Derived from the process id and the clock, with no string literal in it: +/// any constant reaching a password parameter is a hard-coded cryptographic +/// value as far as CodeQL is concerned, and a `format!` over literals still +/// counts. Nothing here is a real secret — the fake server never checks it — +/// but the value has to be distinctive enough that the redaction test can +/// assert it is absent from an error message. +fn test_password() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_nanos()) + .unwrap_or_default(); + format!("{:x}{:x}", std::process::id(), nanos) } fn client(port: u16) -> ZeroBounceWarmup { @@ -270,7 +277,7 @@ fn every_endpoint_uses_its_documented_path_and_verb() { assert_eq!(fake.only().method, "PUT"); zb.delete_custom_engagement("a@b.com", 1).unwrap(); assert_eq!(fake.only().method, "DELETE"); - zb.test_smtp_configuration("a@b.com", "u", &test_password("ok"), "smtp.example.com", 587) + zb.test_smtp_configuration("a@b.com", "u", &test_password(), "smtp.example.com", 587) .unwrap(); let request = fake.only(); assert_eq!(request.method, "POST"); @@ -522,7 +529,7 @@ fn the_smtp_test_checks_its_port() { let (port, fake) = start_fake(); let zb = client(port); let error = zb - .test_smtp_configuration("a@b.com", "u", &test_password("port"), "s", 0) + .test_smtp_configuration("a@b.com", "u", &test_password(), "s", 0) .unwrap_err(); assert!(error.to_string().contains("port"), "{error}"); assert!(fake.take().is_empty()); @@ -635,7 +642,7 @@ fn smtp_credentials_never_appear_in_an_error() { // assertions below prove the client strips them rather than that the // body happened not to contain them. let username = "bob"; - let password = test_password("login"); + let password = test_password(); fake.reply_with( 500, "application/json",