Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/sdk_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
56 changes: 56 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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<u32>`: `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.
86 changes: 86 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
116 changes: 63 additions & 53 deletions src/api/bulk.rs
Original file line number Diff line number Diff line change
@@ -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<ZBFileFeedback> {
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 {
Expand All @@ -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::<ZBFileFeedback>(&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<ZBFileStatus> {
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::<ZBFileStatus>(&response_content)?;
Ok(file_status)
crate::utility::http_error::parse_json_body(&response_content)
}

fn generic_result_fetch(
Expand All @@ -59,6 +52,7 @@ impl ZeroBounce {
options: Option<&ZBGetFileOptions>,
is_scoring: bool,
) -> ZBResult<ZBBulkResponse> {
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()),
Expand All @@ -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() },
));
}
}
}
Expand All @@ -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<ZBFileFeedback>{
let query_args = HashMap::from([
("file_id", file_id),
]);
fn generic_result_delete(&self, endpoint: &str, file_id: &str) -> ZBResult<ZBFileFeedback> {
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::<ZBFileFeedback>(&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<ZBFileFeedback> {
Expand Down Expand Up @@ -172,6 +184,4 @@ impl ZeroBounce {
pub fn ai_scoring_result_delete(&self, file_id: &str) -> ZBResult<ZBFileFeedback> {
self.generic_result_delete(ENDPOINT_SCORING_DELETE, file_id)
}


}
28 changes: 27 additions & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod bulk;
pub mod priority3;
pub mod validation;

use std::collections::HashMap;
Expand All @@ -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.
///
Expand Down Expand Up @@ -115,6 +117,30 @@ impl ZeroBounce {
Ok(activity_data)
}

pub fn score_email(&self, email: &str) -> ZBResult<ScoreEmailResponse> {
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::<ScoreEmailResponse>(&response_content)?;
Ok(score)
}

pub fn score_email_post(&self, email: &str) -> ZBResult<ScoreEmailResponse> {
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::<ScoreEmailResponse>(&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.
Expand Down
Loading
Loading