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..7c5aab7
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,81 @@
+# 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 the S3 / SFTP bulk jobs. Finder and domain search paths are on the bulk host without `/v2`. The six file jobs go through `bulk_root_file_job`; two shapes differ from the rest and are easy to get wrong — `validate_sftp_file`'s `file_name` is optional (hence `Option`), and `export_sftp_file` takes `export_path`, not `export_bucket`.
+- `src/api/inbox_placement.rs`: Inbox Placement Tester.
+- `src/utility/http_error.rs`: turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short `ZBError`.
+- `tests/offline_api.rs`: offline suite. A local HTTP server records every request. The client is pointed at `http://127.0.0.1`, which the URL check allows.
+- 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 audit # check dependencies against the advisory database
+```
+
+The live API suite is behind the `live` feature, so `cargo test` and CI
+never compile or run it:
+
+```bash
+export ZEROBOUNCE_API_KEY=... # validation API
+export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API
+cargo test --features live --test live_api -- --nocapture # free calls only
+ZEROBOUNCE_LIVE_SPEND=1 cargo test --features live --test live_api -- --nocapture
+ZEROBOUNCE_LIVE_CHANGES=1 cargo test --features live --test live_api -- --nocapture
+```
+
+Keys come from the environment only. Never write one into a file, a commit, a
+log, a fixture or a message.
+
+`cargo test` compiles the crate and the integration tests. That is the typecheck. CI (`.github/workflows/sdk_ci.yml`) runs `cargo clippy --all-targets` and `cargo test --no-fail-fast`. Clippy is not denied: existing tests use `assert_eq!` on booleans, and `cargo fmt --check` does not pass on the examples. Do not reformat files you did not change.
+
+## Conventions
+
+- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 bulk endpoints live under https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, not the main API reference.
+- **The list evaluator and the Inbox Placement Tester have their own base URLs** on `ZBUrlProvider` (`list_evaluator_url`, `inbox_placement_url`). They are not built from `bulk_url`, and `send_evaluated_file` stays on the bulk base. Offline tests must point both at the fake server or they reach the live API.
+- **Where the live API and the docs disagree, follow the API and say so in a comment at the call site.** The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer`, not `api_key`; its listing is `GET /tests`, not `/test`; `send-evaluated-file` takes `file_id` as a form field, not a file upload; the S3 and SFTP jobs require `return_url` and `file_name`, `validateSFTPfile` included; the Warmup `check-domain-dmarc` takes an email address, not a domain; and a failed SMTP login in `test-smtp-configuration` comes back as an HTTP 500.
+- **The Inbox Placement Tester takes the API key as an `Authorization: Bearer` header and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`.
+- **`{"success": false}` on an HTTP 200 is a failure.** The API answers an unknown bulk file id that way. `ensure_json_api_body` turns it into a `ZBError::ApiError` carrying the status.
+- **`src/warmup.rs` is a separate client for the Warmup API**, a different product with its own host and its own team-scoped key. It is not part of `ZeroBounce`.
+
+- **One `ZeroBounce` per API key.** `api_key` is a field on the value. Do not share one value across users. The offline concurrency test builds a separate client per key.
+- **Column indexes.** `email_address_column` is a `u32` and is sent as given, including `0`. Name, gender, and IP columns are `Option`: `None` is omitted, and `Some(0)` is sent. Bulk finder `domain_column` of `0` is rejected.
+- **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. A failed API response is `ZBError::ApiError`, whose status is reachable through `ZBError::status_code()`; the key is never in the message.
+- **Tests must not call the live API.** Point `url_provider` at `http://127.0.0.1` or use `mockito`.
+- Match the surrounding style. Do not reformat unrelated code.
+
+## 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.
+
+**Run the live suite by hand before every release.** The offline suite proves the SDK sends what we think it sends; only the live suite proves the API accepts it. Run it with real keys, including `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`, and do not release on a failure.
+
+1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links at the bottom of `CHANGELOG.md`.
+2. Set the same version in `Cargo.toml` `version` and in the `zero-bounce` package entry in `Cargo.lock`. The publish workflow fails if the tag and `Cargo.toml` disagree.
+3. `cargo test --no-fail-fast`.
+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..4cb853c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,107 @@
+# 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`, `email_finder_s3_file`, `domain_search_s3_file`).
+- SFTP/FTP bulk: `validate_sftp_file`, `score_sftp_file`, `email_finder_sftp_file`, `domain_search_sftp_file`, and `export_sftp_file`. The server, path and protocol come from your ZeroBounce settings. `validate_sftp_file` takes `file_name` as `Option` because the docs list it as optional there alone; `export_sftp_file` takes `export_path` rather than the `export_bucket` `export_s3_file` uses.
+- `ZBValidateOptions` accepts `domain_info`, `greylist_processing`, and `callback_url`. `callback_url` needs `greylist_processing: Some(true)` and is rejected without it, and when blank, before a request is sent.
+- `ZBValidation` carries `domain_website_exists`, `domain_registrant_company_name`, and `message`. `message` is read from the API's `Message` key, returned while greylist processing is still running.
+- Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `get_inbox_placement_providers`, `get_inbox_placement_test_types`, `create_inbox_placement_test`, `get_inbox_placement_test`, `list_inbox_placement_tests`, `delete_inbox_placement_test`, `get_inbox_placement_webhook`, `set_inbox_placement_webhook`.
+- `ZBUrlProvider` gains `list_evaluator_url` and `inbox_placement_url`, both overridable.
+- `ZeroBounceWarmup`, a client for the Warmup API. It is a separate product with its own host (`https://warmup-api.zerobounce.net`), its own team-scoped key sent in an `API-Key` header, and all 26 documented endpoints, including the seed-list and statistics CSV downloads.
+- `ZBError::ApiError { status, message }` and `ZBError::status_code()`, so callers can tell a Cloudflare 403 from an API 400/404 and a Warmup 429.
+- An opt-in live API suite behind the `live` cargo feature. `cargo test` and CI never compile it; running it takes `cargo test --features live --test live_api` with keys in the environment, and it only spends credits or changes the account behind `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`.
+
+### Changed
+
+- List evaluator requests use `ZBUrlProvider::list_evaluator_url` instead of building the path from the bulk base, and the file id is URL-encoded as one path segment. `send_evaluated_file` stays on the bulk base.
+
+- API requests time out after 120 seconds and do not follow redirects.
+- `batch_validate` sends `timeout` as a JSON string. Sent as a number, the live API rejects the whole batch with an account-level error.
+- The Inbox Placement Tester authenticates with an `Authorization: Bearer` header instead of an `api_key` parameter, so the key no longer reaches a query string that gets logged.
+- `send_evaluated_file(file_id)` posts the list evaluator's `file_id` as a form field. The endpoint rejects a file upload with "file_id: This field is required", so it no longer takes a file.
+- The S3 and SFTP/FTP jobs require `file_name` and `return_url`, `validate_sftp_file` included, so both are now `&str` rather than `Option`. The API answers "This field is required" otherwise.
+- A base URL must be `https://`, except `http://127.0.0.1`, `http://localhost`, and `http://[::1]` for tests.
+
+### Fixed
+
+- `get_api_usage` no longer fails outright. The API returns only the counters that apply to the account and the date range asked for, and an ordinary response carries neither `sub_status_gold` nor `sub_status_role_based_accept_all`; deserializing them as required fields failed the whole call, reported as "ZeroBounce returned a response that is not valid JSON". Every counter on `ApiUsage` now defaults to 0 when absent, so a counter the API adds or drops later is no longer fatal. `sub_status_leading_period_removed` and `sub_status_alias_address`, which the API does return, are now read.
+
+- `{"success": false}` on an HTTP 200 is an error instead of a result. The API answers an unknown bulk file id that way.
+- An error body whose JSON carries a `detail` string (the Inbox Placement Tester, Django REST framework) returns that text.
+
+- 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.
+- A Warmup API key with surrounding whitespace or control characters is rejected, because the transport would otherwise quote it unredacted in its own error.
+- The Warmup SMTP test masks the username and password out of every error it raises.
+
+## [2.1.2] - 2026-06-10
+
+### 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..fed37dd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,10 +14,19 @@ 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"
+[features]
+# The live API suite. Off by default, so `cargo test` and CI never touch the
+# network: run it with `cargo test --features live --test live_api`.
+live = []
+
+[[test]]
+name = "live_api"
+required-features = ["live"]
+
[dev-dependencies]
mockito = "1.0.2"
dotenv = "0.15"
diff --git a/README.md b/README.md
index 783982a..07d4fab 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,116 @@ let zb = ZeroBounce::with_base_url("your_api_key", "https://custom-api.example.c
See the [config_options example](https://github.com/zerobounce/zerobounce-rust-api/tree/main/examples/config_options.rs) for a complete demonstration of all configuration options.
+## Validate options
+
+```rust
+use zero_bounce::utility::structures::validate_options::ZBValidateOptions;
+use zero_bounce::ZeroBounce;
+
+let zb = ZeroBounce::new("");
+
+let options = ZBValidateOptions {
+ // domain_info adds domain_website_exists and domain_registrant_company_name.
+ domain_info: Some(true),
+ // greylist_processing re-runs a greylisted address instead of returning unknown.
+ greylist_processing: Some(true),
+ // callback_url is only allowed together with greylist_processing: Some(true).
+ callback_url: Some("https://example.com/zerobounce-callback".to_string()),
+ ..Default::default()
+};
+
+let validation = zb.validate_email_with_options("someone@example.com", "", &options)?;
+
+// While the greylist retry is still running the API returns a message instead
+// of a result; validate again in about 30 minutes to pick it up.
+println!("{:?} {:?}", validation.message, validation.domain_website_exists);
+```
+
+## Inbox Placement Tester
+
+Lives on `https://bulkapi.zerobounce.net/api/ipt` and spends inbox test credits.
+
+```rust
+use zero_bounce::api::inbox_placement::{
+ CreateInboxPlacementTestOptions, ListInboxPlacementTestsOptions,
+};
+use zero_bounce::ZeroBounce;
+
+let zb = ZeroBounce::new("");
+
+let providers = zb.get_inbox_placement_providers()?;
+let test_types = zb.get_inbox_placement_test_types()?;
+
+// test_type 1 (Email) needs sender_email; 2 is Unique ID and 3 is Auto Alias.
+let created = zb.create_inbox_placement_test(
+ "October campaign", // required, at most 64 characters
+ &CreateInboxPlacementTestOptions {
+ test_type: Some(1),
+ sender_email: Some("news@example.com".to_string()),
+ webhook_url: Some("https://example.com/ipt-webhook".to_string()),
+ ..Default::default()
+ },
+)?;
+
+// Send your campaign to the seeds in `created`, then read the results.
+let result = zb.get_inbox_placement_test("123")?;
+
+let tests = zb.list_inbox_placement_tests(&ListInboxPlacementTestsOptions {
+ status: Some("finished".to_string()),
+ page: Some(1),
+ page_size: Some(25),
+ ..Default::default()
+})?;
+
+zb.set_inbox_placement_webhook("https://example.com/ipt-webhook")?;
+let webhook = zb.get_inbox_placement_webhook()?;
+zb.delete_inbox_placement_test("123")?;
+```
+
+## S3 and SFTP/FTP bulk
+
+Queue a job on a file that already sits in your Amazon S3 bucket, or on your
+SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api)
+and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs.
+
+```rust
+let zb = ZeroBounce::new("");
+
+// Amazon S3. file_name is the full path, as returned by list_s3_files.
+zb.email_finder_s3_file("lists/leads.csv", true, None, None)?;
+zb.domain_search_s3_file(
+ "lists/leads.csv",
+ true,
+ Some(true), // remove_duplicates
+ Some("https://example.com/zerobounce-done"), // return_url
+)?;
+
+// SFTP/FTP. The server, path and protocol come from your ZeroBounce settings.
+// file_name is optional for validate_sftp_file alone — None uses the input
+// bucket from those settings.
+zb.validate_sftp_file(None, true, None, None)?;
+zb.score_sftp_file("my_file.txt", true, None, None)?;
+zb.email_finder_sftp_file("my_file.txt", true, None, None)?;
+zb.domain_search_sftp_file("my_file.txt", true, None, None)?;
+
+// Note the field: this one takes export_path, not export_bucket.
+// None uses the export path from your settings.
+zb.export_sftp_file("", Some("/exports"))?;
+```
+
+## Per-API base URLs
+
+The list evaluator and the Inbox Placement Tester have their own base URLs,
+separate from the main and bulk hosts.
+
+```rust
+let mut zb = ZeroBounce::new("");
+zb.url_provider.list_evaluator_url =
+ "https://bulkapi.zerobounce.net/v2/listevaluator".to_string();
+zb.url_provider.inbox_placement_url =
+ "https://bulkapi.zerobounce.net/api/ipt".to_string();
+```
+
## Email Finding Methods
### find_email_v2 (Recommended)
@@ -635,6 +745,68 @@ cargo run --example # list of available examples
cargo run --example domain_search_v2
```
+## Warmup API
+
+The Warmup API is a separate product. It has its own host, its own team-scoped key, and its own client; your validation key will not work with it.
+
+```rust
+use zero_bounce::{OnboardingModel, ZeroBounceWarmup};
+
+let warmup = ZeroBounceWarmup::new("")?;
+
+let accounts = warmup.list_sending_accounts()?;
+let progress = warmup.get_progress_today()?;
+let csv = warmup.download_seeds()?; // CSV text, columns seed,provider
+```
+
+Add a from-address you run yourself, with one of the five standard engagement rules (0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect):
+
+```rust
+warmup.add_sending_account(
+ "sender@example.com",
+ OnboardingModel::SelfManaged,
+ Some(0),
+ None,
+ None,
+ None,
+)?;
+```
+
+A custom engagement rule can be changed only once every seven days; inside that window the API answers 429.
+
+```rust
+warmup.create_custom_engagement("sender@example.com", 20, 20, 35)?; // reply, important, click %
+```
+
+`check_domain_dmarc` takes an **email address**, not a domain — the live API rejects a bare domain even though the docs describe one:
+
+```rust
+let dmarc = warmup.check_domain_dmarc("sender@example.com", None)?;
+```
+
+Every documented empty success (204, or an empty 200) comes back as `Ok(None)`. Failures are `ZBError::ApiError`, whose status is reachable through `ZBError::status_code()`. Nothing is stored by `test_smtp_configuration`, and the credentials you pass it are masked out of any error it raises.
+
+## Live API suite
+
+`cargo test` never touches the network: the live suite is behind the `live` cargo feature, so CI does not even compile it. It needs real keys, which come from the environment and are never written anywhere:
+
+```bash
+export ZEROBOUNCE_API_KEY=... # validation API
+export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API
+cargo test --features live --test live_api -- --nocapture
+```
+
+By default it only makes free calls: sandbox addresses, reads, and requests that are expected to fail but still prove the API accepts the payload. It checks that the credit balance did not move. Two flags opt in to more:
+
+| Flag | What it adds |
+| --- | --- |
+| `ZEROBOUNCE_LIVE_SPEND=1` | billable calls (scoring, activity, email finder) and a bulk round trip that deletes its own job |
+| `ZEROBOUNCE_LIVE_CHANGES=1` | reversible account changes — a test filter and a self-managed Warmup sending account — each undone in cleanup even when a test fails |
+
+It never creates an Inbox Placement test, sets the Inbox Placement webhook, or starts ZB-managed Warmup onboarding, because the API cannot undo any of those.
+
+Run it by hand before every release: the offline suite proves the SDK sends what we think it sends, and only the live suite proves the API accepts it.
+
## Publish
1. Bump `version` in `Cargo.toml`, commit, tag (`vX.Y.Z`), push tag.
diff --git a/documentation.md b/documentation.md
index 526d27e..7be1cf4 100644
--- a/documentation.md
+++ b/documentation.md
@@ -51,6 +51,116 @@ let zb = ZeroBounce::with_base_url("your_api_key", "https://custom-api.example.c
See the [config_options example](https://github.com/zerobounce/zerobounce-rust-api/tree/main/examples/config_options.rs) for a complete demonstration of all configuration options.
+## Validate options
+
+```rust
+use zero_bounce::utility::structures::validate_options::ZBValidateOptions;
+use zero_bounce::ZeroBounce;
+
+let zb = ZeroBounce::new("");
+
+let options = ZBValidateOptions {
+ // domain_info adds domain_website_exists and domain_registrant_company_name.
+ domain_info: Some(true),
+ // greylist_processing re-runs a greylisted address instead of returning unknown.
+ greylist_processing: Some(true),
+ // callback_url is only allowed together with greylist_processing: Some(true).
+ callback_url: Some("https://example.com/zerobounce-callback".to_string()),
+ ..Default::default()
+};
+
+let validation = zb.validate_email_with_options("someone@example.com", "", &options)?;
+
+// While the greylist retry is still running the API returns a message instead
+// of a result; validate again in about 30 minutes to pick it up.
+println!("{:?} {:?}", validation.message, validation.domain_website_exists);
+```
+
+## Inbox Placement Tester
+
+Lives on `https://bulkapi.zerobounce.net/api/ipt` and spends inbox test credits.
+
+```rust
+use zero_bounce::api::inbox_placement::{
+ CreateInboxPlacementTestOptions, ListInboxPlacementTestsOptions,
+};
+use zero_bounce::ZeroBounce;
+
+let zb = ZeroBounce::new("");
+
+let providers = zb.get_inbox_placement_providers()?;
+let test_types = zb.get_inbox_placement_test_types()?;
+
+// test_type 1 (Email) needs sender_email; 2 is Unique ID and 3 is Auto Alias.
+let created = zb.create_inbox_placement_test(
+ "October campaign", // required, at most 64 characters
+ &CreateInboxPlacementTestOptions {
+ test_type: Some(1),
+ sender_email: Some("news@example.com".to_string()),
+ webhook_url: Some("https://example.com/ipt-webhook".to_string()),
+ ..Default::default()
+ },
+)?;
+
+// Send your campaign to the seeds in `created`, then read the results.
+let result = zb.get_inbox_placement_test("123")?;
+
+let tests = zb.list_inbox_placement_tests(&ListInboxPlacementTestsOptions {
+ status: Some("finished".to_string()),
+ page: Some(1),
+ page_size: Some(25),
+ ..Default::default()
+})?;
+
+zb.set_inbox_placement_webhook("https://example.com/ipt-webhook")?;
+let webhook = zb.get_inbox_placement_webhook()?;
+zb.delete_inbox_placement_test("123")?;
+```
+
+## S3 and SFTP/FTP bulk
+
+Queue a job on a file that already sits in your Amazon S3 bucket, or on your
+SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api)
+and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs.
+
+```rust
+let zb = ZeroBounce::new("");
+
+// Amazon S3. file_name is the full path, as returned by list_s3_files.
+zb.email_finder_s3_file("lists/leads.csv", true, None, None)?;
+zb.domain_search_s3_file(
+ "lists/leads.csv",
+ true,
+ Some(true), // remove_duplicates
+ Some("https://example.com/zerobounce-done"), // return_url
+)?;
+
+// SFTP/FTP. The server, path and protocol come from your ZeroBounce settings.
+// file_name is optional for validate_sftp_file alone — None uses the input
+// bucket from those settings.
+zb.validate_sftp_file(None, true, None, None)?;
+zb.score_sftp_file("my_file.txt", true, None, None)?;
+zb.email_finder_sftp_file("my_file.txt", true, None, None)?;
+zb.domain_search_sftp_file("my_file.txt", true, None, None)?;
+
+// Note the field: this one takes export_path, not export_bucket.
+// None uses the export path from your settings.
+zb.export_sftp_file("", Some("/exports"))?;
+```
+
+## Per-API base URLs
+
+The list evaluator and the Inbox Placement Tester have their own base URLs,
+separate from the main and bulk hosts.
+
+```rust
+let mut zb = ZeroBounce::new("");
+zb.url_provider.list_evaluator_url =
+ "https://bulkapi.zerobounce.net/v2/listevaluator".to_string();
+zb.url_provider.inbox_placement_url =
+ "https://bulkapi.zerobounce.net/api/ipt".to_string();
+```
+
## Email Finding Methods
### find_email_v2 (Recommended)
diff --git a/src/api/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/inbox_placement.rs b/src/api/inbox_placement.rs
new file mode 100644
index 0000000..7c69ebb
--- /dev/null
+++ b/src/api/inbox_placement.rs
@@ -0,0 +1,272 @@
+//! Inbox Placement Tester (bulkapi.zerobounce.net/api/ipt).
+//!
+//! The API key goes on the query string rather than in the body, and bodies are
+//! JSON with camelCase fields. Only fields the caller set are sent. Uses the
+//! account's inbox-test credits.
+
+use std::collections::HashMap;
+
+use serde_json::{json, Map, Value};
+
+use crate::utility::{encode_path_segment, ZBError, ZBResult};
+use crate::ZeroBounce;
+
+const TEST_TYPES: [u8; 3] = [1, 2, 3];
+const TEST_STATUSES: [&str; 2] = ["running", "finished"];
+
+/// A per-provider seed cap for [`ZeroBounce::create_inbox_placement_test`].
+#[derive(Clone, Debug)]
+pub struct InboxPlacementProviderSelection {
+ pub id: i64,
+ /// At least 1 when given.
+ pub max_seeds: Option,
+}
+
+/// Options for [`ZeroBounce::create_inbox_placement_test`]. Unset fields are not sent.
+#[derive(Clone, Debug, Default)]
+pub struct CreateInboxPlacementTestOptions {
+ /// 1 (Email, needs `sender_email`), 2 (Unique ID) or 3 (Auto Alias).
+ pub test_type: Option,
+ /// The From address to match; required when `test_type` is 1.
+ pub sender_email: Option,
+ /// Use every active seed of these providers.
+ pub provider_ids: Option>,
+ /// Use these seed mailboxes.
+ pub seed_ids: Option>,
+ /// Cap the seeds taken from each provider.
+ pub providers: Option>,
+ /// Where the finished result is posted.
+ pub webhook_url: Option,
+}
+
+/// Filters for [`ZeroBounce::list_inbox_placement_tests`]. Unset fields are not sent.
+#[derive(Clone, Debug, Default)]
+pub struct ListInboxPlacementTestsOptions {
+ /// "running" or "finished".
+ pub status: Option,
+ /// ISO 8601 date or datetime.
+ pub created_after: Option,
+ /// ISO 8601 date or datetime.
+ pub created_before: Option,
+ /// From 1.
+ pub page: Option,
+ /// 1 to 100.
+ pub page_size: Option,
+}
+
+impl ZeroBounce {
+ /// Seed providers available for new tests. `GET /providers`
+ pub fn get_inbox_placement_providers(&self) -> ZBResult {
+ self.inbox_placement_get("/providers", HashMap::new())
+ }
+
+ /// Supported test types and the default one. `GET /test-types`
+ pub fn get_inbox_placement_test_types(&self) -> ZBResult {
+ self.inbox_placement_get("/test-types", HashMap::new())
+ }
+
+ /// Create an inbox placement test. `POST /test`
+ ///
+ /// `name` must be non-empty and at most 64 characters. Spends one inbox test.
+ pub fn create_inbox_placement_test(
+ &self,
+ name: &str,
+ options: &CreateInboxPlacementTestOptions,
+ ) -> ZBResult {
+ if name.trim().is_empty() {
+ return Err(ZBError::explicit("Empty parameter: name"));
+ }
+ if name.chars().count() > 64 {
+ return Err(ZBError::explicit(
+ "Invalid parameter: name (at most 64 characters)",
+ ));
+ }
+ if let Some(test_type) = options.test_type {
+ if !TEST_TYPES.contains(&test_type) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: test_type (must be 1, 2 or 3)",
+ ));
+ }
+ if test_type == 1
+ && options
+ .sender_email
+ .as_deref()
+ .map(|value| value.trim().is_empty())
+ .unwrap_or(true)
+ {
+ return Err(ZBError::explicit(
+ "Empty parameter: sender_email (required when test_type is 1)",
+ ));
+ }
+ }
+ for provider in options.providers.iter().flatten() {
+ if provider.max_seeds.map(|seeds| seeds < 1).unwrap_or(false) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: providers (max_seeds must be at least 1)",
+ ));
+ }
+ }
+
+ let mut body = Map::new();
+ body.insert("name".to_string(), json!(name));
+ if let Some(test_type) = options.test_type {
+ body.insert("testType".to_string(), json!(test_type));
+ }
+ if let Some(sender_email) = options.sender_email.as_deref() {
+ body.insert("senderEmail".to_string(), json!(sender_email));
+ }
+ if let Some(provider_ids) = options.provider_ids.as_ref() {
+ body.insert("providerIds".to_string(), json!(provider_ids));
+ }
+ if let Some(seed_ids) = options.seed_ids.as_ref() {
+ body.insert("seedIds".to_string(), json!(seed_ids));
+ }
+ if let Some(providers) = options.providers.as_ref() {
+ let entries: Vec = providers
+ .iter()
+ .map(|provider| match provider.max_seeds {
+ Some(max_seeds) => json!({"id": provider.id, "maxSeeds": max_seeds}),
+ None => json!({"id": provider.id}),
+ })
+ .collect();
+ body.insert("providers".to_string(), Value::Array(entries));
+ }
+ if let Some(webhook_url) = options.webhook_url.as_deref() {
+ body.insert("webhookUrl".to_string(), json!(webhook_url));
+ }
+ self.inbox_placement_json("POST", "/test", Value::Object(body))
+ }
+
+ /// A test and its placement results. `GET /test/{id}`
+ pub fn get_inbox_placement_test(&self, test_id: &str) -> ZBResult {
+ let path = format!("/test/{}", Self::inbox_placement_test_path(test_id)?);
+ self.inbox_placement_get(&path, HashMap::new())
+ }
+
+ /// List tests, newest first. `GET /tests`
+ pub fn list_inbox_placement_tests(
+ &self,
+ options: &ListInboxPlacementTestsOptions,
+ ) -> ZBResult {
+ if let Some(status) = options.status.as_deref() {
+ if !TEST_STATUSES.contains(&status) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: status (must be running or finished)",
+ ));
+ }
+ }
+ if options.page.map(|page| page < 1).unwrap_or(false) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: page (must be at least 1)",
+ ));
+ }
+ if options
+ .page_size
+ .map(|size| !(1..=100).contains(&size))
+ .unwrap_or(false)
+ {
+ return Err(ZBError::explicit(
+ "Invalid parameter: page_size (must be between 1 and 100)",
+ ));
+ }
+
+ let page_param;
+ let page_size_param;
+ let mut query: HashMap<&str, &str> = HashMap::new();
+ if let Some(status) = options.status.as_deref() {
+ query.insert("status", status);
+ }
+ if let Some(created_after) = options.created_after.as_deref() {
+ query.insert("createdAfter", created_after);
+ }
+ if let Some(created_before) = options.created_before.as_deref() {
+ query.insert("createdBefore", created_before);
+ }
+ if let Some(page) = options.page {
+ page_param = page.to_string();
+ query.insert("page", &page_param);
+ }
+ if let Some(page_size) = options.page_size {
+ page_size_param = page_size.to_string();
+ query.insert("page_size", &page_size_param);
+ }
+ self.inbox_placement_get("/tests", query)
+ }
+
+ /// Delete a finished test. `DELETE /test/{id}`
+ pub fn delete_inbox_placement_test(&self, test_id: &str) -> ZBResult {
+ let path = format!("/test/{}", Self::inbox_placement_test_path(test_id)?);
+ self.inbox_placement_request("DELETE", &path, HashMap::new(), None)
+ }
+
+ /// The webhook URL saved on this API key. `GET /webhook`
+ pub fn get_inbox_placement_webhook(&self) -> ZBResult {
+ self.inbox_placement_get("/webhook", HashMap::new())
+ }
+
+ /// Save the URL finished results are posted to. `PUT /webhook`
+ pub fn set_inbox_placement_webhook(&self, webhook_url: &str) -> ZBResult {
+ if webhook_url.trim().is_empty() {
+ return Err(ZBError::explicit("Empty parameter: webhook_url"));
+ }
+ self.inbox_placement_json("PUT", "/webhook", json!({ "webhookUrl": webhook_url }))
+ }
+
+ fn inbox_placement_test_path(test_id: &str) -> ZBResult {
+ if test_id.trim().is_empty() {
+ return Err(ZBError::explicit("Empty parameter: test_id"));
+ }
+ Ok(encode_path_segment(test_id.trim()))
+ }
+
+ fn inbox_placement_get<'a>(
+ &'a self,
+ path: &str,
+ query: HashMap<&'a str, &'a str>,
+ ) -> ZBResult {
+ self.inbox_placement_request("GET", path, query, None)
+ }
+
+ fn inbox_placement_json(&self, method: &str, path: &str, body: Value) -> ZBResult {
+ self.inbox_placement_request(method, path, HashMap::new(), Some(body))
+ }
+
+ fn inbox_placement_request<'a>(
+ &'a self,
+ method: &str,
+ path: &str,
+ query: HashMap<&'a str, &'a str>,
+ body: Option,
+ ) -> ZBResult {
+ self.require_key()?;
+
+ let url = self.url_provider.inbox_placement_url_of(path);
+ let parsed = reqwest::Url::parse(&url)
+ .map_err(|_| ZBError::explicit("Invalid parameter: inbox placement base URL"))?;
+
+ let mut request = match method {
+ "POST" => self.client.post(parsed),
+ "PUT" => self.client.put(parsed),
+ "DELETE" => self.client.delete(parsed),
+ _ => self.client.get(parsed),
+ }
+ .query(&query)
+ // LIVE: the Inbox Placement Tester authenticates with a Bearer header.
+ // Sent as an api_key parameter the request is rejected as unauthorized.
+ .bearer_auth(self.api_key.as_str());
+
+ if let Some(body) = body {
+ // reqwest's json() helper needs its "json" feature, which this crate
+ // does not enable, so the body and content type are set directly.
+ let encoded = serde_json::to_string(&body)
+ .map_err(|_| ZBError::explicit("Invalid parameter: request body"))?;
+ request = request
+ .header(reqwest::header::CONTENT_TYPE, "application/json")
+ .body(encoded);
+ }
+
+ let response = request.send()?;
+ let content = self.read_api_response(response)?;
+ crate::utility::http_error::parse_json_body(&content)
+ }
+}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 2ed98ee..ee6b73b 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -1,4 +1,6 @@
pub mod bulk;
+pub mod inbox_placement;
+pub mod priority3;
pub mod validation;
use std::collections::HashMap;
@@ -10,7 +12,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 +118,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..f28acf1
--- /dev/null
+++ b/src/api/priority3.rs
@@ -0,0 +1,469 @@
+use std::collections::HashMap;
+use std::io::Read;
+
+use reqwest::blocking::multipart::{Form, Part};
+use serde_json::{from_str, Value};
+
+use serde::Deserialize;
+
+use crate::utility::structures::bulk::ZBFileFeedback;
+use crate::utility::{ZBError, ZBResult};
+use crate::ZeroBounce;
+
+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_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(crate::parse_request_url(&url)?)
+ .form(&form)
+ .send()?;
+ let body = client.read_api_response(response)?;
+ crate::utility::http_error::parse_json_body(&body)
+}
+
+/// POST one of the documented S3 / SFTP file jobs on the bulkapi root.
+///
+/// LIVE: `file_name` and `return_url` are required on every one of these,
+/// including `validateSFTPfile`, which the docs list `file_name` as optional
+/// for. Without either the API answers "This field is required".
+fn bulk_root_file_job(
+ client: &ZeroBounce,
+ path: &str,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: &str,
+) -> ZBResult {
+ if file_name.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: file_name"));
+ }
+ if return_url.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: return_url"));
+ }
+
+ let mut fields: HashMap = HashMap::new();
+ fields.insert("has_header_row".to_string(), has_header_row.to_string());
+ fields.insert("file_name".to_string(), file_name.to_string());
+ fields.insert("return_url".to_string(), return_url.to_string());
+ if let Some(remove_duplicates) = remove_duplicates {
+ fields.insert(
+ "remove_duplicates".to_string(),
+ remove_duplicates.to_string(),
+ );
+ }
+ post_bulk_root_form(client, path, fields)
+}
+
+#[derive(Deserialize, Debug)]
+pub struct S3ListFileEntry {
+ pub name: String,
+ pub size: i64,
+ pub last_modified: String,
+}
+
+fn post_bulk_root_multipart(
+ client: &ZeroBounce,
+ url: String,
+ 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 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)
+ }
+
+ /// Submit a finished evaluation for validation. `POST /v2/send-evaluated-file/`
+ ///
+ /// LIVE: this takes the list evaluator's `file_id` as a form field, not the
+ /// file upload the docs describe — an upload is rejected with
+ /// "file_id: This field is required". It returns a new validation job's
+ /// `file_id`, which answers "File cannot be found" for a short while after
+ /// it is accepted.
+ pub fn send_evaluated_file(&self, file_id: &str) -> ZBResult {
+ if file_id.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: file_id"));
+ }
+ self.require_key()?;
+ let url = format!(
+ "{}/v2/send-evaluated-file/",
+ bulk_api_root(&self.url_provider.bulk_url)
+ );
+ let form = HashMap::from([
+ ("api_key", self.api_key.clone()),
+ ("file_id", file_id.to_string()),
+ ]);
+ let response = self
+ .client
+ .post(crate::parse_request_url(&url)?)
+ .form(&form)
+ .send()?;
+ let body = self.read_api_response(response)?;
+ crate::utility::http_error::parse_json_body(&body)
+ }
+
+ pub fn send_domain_search_file(
+ &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!(
+ "{}/",
+ self.url_provider.list_evaluator_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!(
+ "{}/{}/",
+ self.url_provider.list_evaluator_url,
+ crate::utility::encode_path_segment(file_id)
+ );
+ let body = self.generic_get_request(url, HashMap::new())?;
+ crate::utility::http_error::parse_json_body(&body)
+ }
+
+ 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()),
+ ]),
+ )
+ }
+
+ /// Run the email finder on a file in your Amazon S3 bucket (POST /emailFinderS3file).
+ ///
+ /// `file_name` is the full path as returned by [`list_s3_files`](Self::list_s3_files).
+ pub fn email_finder_s3_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: &str,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "emailFinderS3file",
+ file_name,
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ )
+ }
+
+ /// Run domain search on a file in your Amazon S3 bucket (POST /domainSearchS3file).
+ pub fn domain_search_s3_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: &str,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "domainSearchS3file",
+ file_name,
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ )
+ }
+
+ // SFTP / FTP bulk. The server, path and protocol come from your ZeroBounce settings.
+
+ /// Validate a file from your SFTP/FTP server (POST /validateSFTPfile).
+ ///
+ /// LIVE: `file_name` is required here too, although the docs list it as
+ /// optional for this one endpoint.
+ pub fn validate_sftp_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: &str,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "validateSFTPfile",
+ file_name,
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ )
+ }
+
+ /// Score a file from your SFTP/FTP server (POST /scoreSFTPfile).
+ ///
+ /// `file_name` is a plain name such as `my_file.txt`.
+ pub fn score_sftp_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: &str,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "scoreSFTPfile",
+ file_name,
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ )
+ }
+
+ /// Run the email finder on a file from your SFTP/FTP server (POST /emailFinderSFTPfile).
+ pub fn email_finder_sftp_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: &str,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "emailFinderSFTPfile",
+ file_name,
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ )
+ }
+
+ /// Run domain search on a file from your SFTP/FTP server (POST /domainSearchSFTPfile).
+ pub fn domain_search_sftp_file(
+ &self,
+ file_name: &str,
+ has_header_row: bool,
+ remove_duplicates: Option,
+ return_url: &str,
+ ) -> ZBResult {
+ bulk_root_file_job(
+ self,
+ "domainSearchSFTPfile",
+ file_name,
+ has_header_row,
+ remove_duplicates,
+ return_url,
+ )
+ }
+
+ /// Export a validated or scored file back to your SFTP/FTP server (POST /exportSFTPfile).
+ ///
+ /// Note the field name: this endpoint takes `export_path`, not the `export_bucket`
+ /// that [`export_s3_file`](Self::export_s3_file) uses. Pass `None` to use the export
+ /// path from your ZeroBounce settings.
+ pub fn export_sftp_file(
+ &self,
+ file_id: &str,
+ export_path: Option<&str>,
+ ) -> ZBResult {
+ if file_id.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: file_id"));
+ }
+ let mut fields = HashMap::from([("file_id".to_string(), file_id.to_string())]);
+ if let Some(export_path) = export_path {
+ if export_path.trim().is_empty() {
+ return Err(ZBError::explicit("empty parameter: export_path"));
+ }
+ fields.insert("export_path".to_string(), export_path.to_string());
+ }
+ post_bulk_root_form(self, "exportSFTPfile", fields)
+ }
+}
diff --git a/src/api/validation.rs b/src/api/validation.rs
index d85a5a0..2b3d3b6 100644
--- a/src/api/validation.rs
+++ b/src/api/validation.rs
@@ -1,31 +1,117 @@
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)",
+ ));
+ }
+ }
+ // callback_url only means anything while greylist processing is on, so it is
+ // rejected here rather than letting the API take the request.
+ if let Some(callback_url) = options.callback_url.as_deref() {
+ if options.greylist_processing != Some(true) {
+ return Err(ZBError::explicit(
+ "Invalid parameter: callback_url requires greylist_processing = true",
+ ));
+ }
+ if callback_url.trim().is_empty() {
+ return Err(ZBError::explicit("Empty parameter: callback_url"));
+ }
+ }
+
+ let mut query_args = HashMap::from([("email", email)]);
+ let timeout_param;
+ let activity_param;
+ let verify_param;
+ let domain_info_param;
+ let greylist_param;
if !ip_address.is_empty() {
query_args.insert("ip_address", ip_address);
}
+ 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);
+ }
+ if let Some(domain_info) = options.domain_info {
+ domain_info_param = if domain_info { "true" } else { "false" };
+ query_args.insert("domain_info", domain_info_param);
+ }
+ if let Some(greylist_processing) = options.greylist_processing {
+ greylist_param = if greylist_processing { "true" } else { "false" };
+ query_args.insert("greylist_processing", greylist_param);
+ }
+ if let Some(callback_url) = options.callback_url.as_deref() {
+ query_args.insert("callback_url", callback_url);
+ }
- let response_content = 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;
+ let domain_info_param;
+ let greylist_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);
+ }
+ if let Some(domain_info) = options.domain_info {
+ domain_info_param = if domain_info { "true" } else { "false" };
+ form_args.insert("domain_info", domain_info_param);
+ }
+ if let Some(greylist_processing) = options.greylist_processing {
+ greylist_param = if greylist_processing { "true" } else { "false" };
+ form_args.insert("greylist_processing", greylist_param);
+ }
+ if let Some(callback_url) = options.callback_url.as_deref() {
+ form_args.insert("callback_url", callback_url);
+ }
+ self.generic_form_post_request(self.url_provider.url_of(ENDPOINT_VALIDATE), form_args)?
+ } else {
+ self.generic_get_request(self.url_provider.url_of(ENDPOINT_VALIDATE), query_args)?
+ };
+
+ crate::utility::http_error::parse_json_body(&response_content)
}
pub fn validate_email(&self, email: &str) -> ZBResult {
@@ -47,23 +133,39 @@ 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 {
+ // LIVE: validatebatch takes timeout as a JSON *string*. Sent as a
+ // number, the API rejects the whole batch with the account-level
+ // "Invalid API Key or your account ran out of credits".
+ request_body_map.insert("timeout".to_string(), Value::String(timeout.to_string()));
+ }
+ if let Some(activity_data) = options.activity_data {
+ request_body_map.insert("activity_data".to_string(), Value::Bool(activity_data));
+ }
+ 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 +179,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 +234,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..1348265 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,23 +1,29 @@
pub mod api;
pub mod utility;
+pub mod warmup;
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};
+pub use crate::warmup::{EmailContent, ManagedSending, OnboardingModel, ZeroBounceWarmup};
// Structure meant to generate the URLs to be accessed with the HTTP requests
// based on the base API URLs (for the base API and bulk API).
pub struct ZBUrlProvider {
pub url: String,
pub bulk_url: String,
+ /// Overridable; the list evaluator path differs from the other bulk endpoints.
+ pub list_evaluator_url: String,
+ /// Overridable; the Inbox Placement Tester is not under the bulk /v2 path.
+ pub inbox_placement_url: String,
}
impl ZBUrlProvider {
@@ -27,6 +33,12 @@ impl ZBUrlProvider {
pub fn bulk_url_of(&self, endpoint: &str) -> String {
self.bulk_url.to_owned() + endpoint
}
+ pub fn list_evaluator_url_of(&self, endpoint: &str) -> String {
+ self.list_evaluator_url.to_owned() + endpoint
+ }
+ pub fn inbox_placement_url_of(&self, endpoint: &str) -> String {
+ self.inbox_placement_url.to_owned() + endpoint
+ }
}
impl Default for ZBUrlProvider {
@@ -34,6 +46,8 @@ impl Default for ZBUrlProvider {
ZBUrlProvider {
url: ApiBaseUrl::Default.as_str().to_string(),
bulk_url: crate::utility::BULK_URI.to_string(),
+ list_evaluator_url: crate::utility::LIST_EVALUATOR_URI.to_string(),
+ inbox_placement_url: crate::utility::INBOX_PLACEMENT_URI.to_string(),
}
}
}
@@ -50,14 +64,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 +79,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 +87,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)
+ }
+}
+
+pub(crate) fn parse_request_url(url: &str) -> ZBResult {
+ let parsed = reqwest::Url::parse(url).map_err(|e| ZBError::ExplicitError(e.to_string()))?;
+ if parsed.scheme() == "https" {
+ return Ok(parsed);
+ }
+ 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..d4334d2
--- /dev/null
+++ b/src/utility/http_error.rs
@@ -0,0 +1,169 @@
+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 {
+ // "detail" is how the Inbox Placement Tester (Django REST framework) reports errors.
+ for key in ["error", "message", "error_message", "Message", "detail"] {
+ match object.get(key) {
+ Some(serde_json::Value::String(text)) if !text.trim().is_empty() => {
+ return text.trim().to_string();
+ }
+ 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
+}
+
+/// Whether a "success" field means failure. The API sends it as a JSON false,
+/// "false" or "False" depending on the endpoint.
+fn is_falsy_api_success(value: &serde_json::Value) -> bool {
+ match value {
+ serde_json::Value::Bool(flag) => !flag,
+ serde_json::Value::String(text) => text.eq_ignore_ascii_case("false"),
+ _ => false,
+ }
+}
+
+/// Accept a 2xx JSON document. Anything else is a short error.
+pub fn ensure_json_api_body(status: u16, content_type: &str, body: &str) -> ZBResult<()> {
+ let success = (200..300).contains(&status);
+ 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::api(
+ status,
+ message_from_body(status, content_type, body),
+ ));
+ }
+ // LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The
+ // API answers an unknown bulk file id that way, with a 200.
+ if trimmed.starts_with('{') {
+ if let Ok(serde_json::Value::Object(object)) =
+ serde_json::from_str::(trimmed)
+ {
+ if object.get("success").is_some_and(is_falsy_api_success) {
+ return Err(ZBError::api(
+ status,
+ message_from_body(status, content_type, body),
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
+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..5ecc190 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};
@@ -47,9 +48,31 @@ impl From<&ApiBaseUrl> for String {
}
}
+/// Percent-encodes a value so it is one path segment.
+///
+/// Only the unreserved set from RFC 3986 is left alone, so `/`, spaces and the
+/// rest are escaped and a file id cannot break out of its segment. Written here
+/// rather than pulling in a crate for four lines.
+pub fn encode_path_segment(value: &str) -> String {
+ let mut encoded = String::with_capacity(value.len());
+ for byte in value.as_bytes() {
+ match byte {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
+ encoded.push(*byte as char)
+ }
+ _ => encoded.push_str(&format!("%{:02X}", byte)),
+ }
+ }
+ encoded
+}
+
pub const BULK_URI: &str = "https://bulkapi.zerobounce.net/v2";
+/// The list evaluator and the Inbox Placement Tester are not under the bulk /v2 path.
+pub const LIST_EVALUATOR_URI: &str = "https://bulkapi.zerobounce.net/v2/listevaluator";
+pub const INBOX_PLACEMENT_URI: &str = "https://bulkapi.zerobounce.net/api/ipt";
pub const ENDPOINT_CREDITS: &str = "/getcredits";
pub const ENDPOINT_ACTIVITY_DATA: &str = "/activity";
+pub const ENDPOINT_SCORING: &str = "/scoring";
pub const ENDPOINT_VALIDATE: &str = "/validate";
pub const ENDPOINT_API_USAGE: &str = "/getapiusage";
pub const ENDPOINT_BATCH_VALIDATE: &str = "/validatebatch";
@@ -97,10 +120,15 @@ pub const SS_ROLE_BASED_CATCH_ALL: &str = "role_based_catch_all";
pub const SS_DISPOSABLE: &str = "disposable";
pub const SS_TOXIC: &str = "toxic";
pub const SS_ACCEPT_ALL: &str = "accept_all";
+pub const SS_AI_AGENT_MAILBOX: &str = "ai_agent_mailbox";
#[derive(Debug)]
pub enum ZBError {
ExplicitError(String),
+ /// A failed API response. The status lets callers tell a 403 from
+ /// Cloudflare apart from a 400/404 from the API and a 429 from the Warmup
+ /// rate limit. Both the validation client and the Warmup client return it.
+ ApiError { status: u16, message: String },
JsonError(serde_json::Error),
IntParseError(std::num::ParseIntError),
RequestError(reqwest::Error),
@@ -113,8 +141,46 @@ impl ZBError {
pub fn explicit(string: &str) -> ZBError {
ZBError::ExplicitError(string.to_string())
}
+
+ pub fn api(status: u16, message: impl Into) -> ZBError {
+ ZBError::ApiError {
+ status,
+ message: message.into(),
+ }
+ }
+
+ /// The HTTP status of a failed API response, when there was one.
+ pub fn status_code(&self) -> Option {
+ match self {
+ ZBError::ApiError { status, .. } => Some(*status),
+ _ => None,
+ }
+ }
}
+impl std::fmt::Display for ZBError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ZBError::ExplicitError(message) => write!(formatter, "{message}"),
+ ZBError::ApiError { message, .. } => write!(formatter, "{message}"),
+ ZBError::JsonError(_) => {
+ write!(
+ formatter,
+ "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 +227,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