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/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/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/mod.rs b/src/api/mod.rs index 2ed98ee..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; @@ -10,7 +11,8 @@ 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::generic::ScoreEmailResponse; +use crate::utility::{ENDPOINT_ACTIVITY_DATA, ENDPOINT_API_USAGE, ENDPOINT_CREDITS, ENDPOINT_SCORING}; /// Builder for the `find_email_v2` API call. /// @@ -115,6 +117,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/api/priority3.rs b/src/api/priority3.rs new file mode 100644 index 0000000..cf1d762 --- /dev/null +++ b/src/api/priority3.rs @@ -0,0 +1,276 @@ +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; +use crate::utility::{ZBError, ZBResult}; +use crate::ZeroBounce; + +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 { + client.require_key()?; + 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 body = client.read_api_response(response)?; + 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", + 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())?; + 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)]), + )?; + 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 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 d85a5a0..395713a 100644 --- a/src/api/validation.rs +++ b/src/api/validation.rs @@ -1,31 +1,78 @@ 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 { - 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 { + 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; + 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 - )?; - - let validation = from_str::(&response_content)?; - Ok(validation) + 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)? + }; + + crate::utility::http_error::parse_json_body(&response_content) } pub fn validate_email(&self, email: &str) -> ZBResult { @@ -47,23 +94,36 @@ 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)| + .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::>(); - 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()); @@ -77,27 +137,52 @@ impl ZeroBounce { Ok(final_string) } - 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)?; + pub fn batch_validate( + &self, + emails_and_ip_addresses: Vec<(String, String)>, + ) -> ZBResult { + 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 { + 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(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)] @@ -107,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); + 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 86a493b..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,55 +73,112 @@ 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(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>( + &'a self, + 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()?; + self.read_api_response(response) + } +} + +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 { 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 9f6f1a3..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}; @@ -50,6 +51,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"; @@ -115,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. // @@ -161,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/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, 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..637ffb3 --- /dev/null +++ b/src/utility/structures/validate_options.rs @@ -0,0 +1,7 @@ +#[derive(Clone, Debug, Default)] +pub struct ZBValidateOptions { + pub timeout: Option, + pub activity_data: Option, + pub verify_plus: Option, + pub use_post: bool, +} 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, 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/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) { 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]