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
{ + self.generate_multipart_with(true) + } + + /// `include_person_columns` is true for validation sendfile. Scoring sendfile omits them. + pub(crate) fn generate_multipart_with(&self, include_person_columns: bool) -> ZBResult { let content_part = self.file_content_multipart()?; let mut multipart_form = Form::new() .part("file", content_part) .text("has_header_row", self.has_header_row.to_string()) .text("remove_duplicate", self.remove_duplicate.to_string()) - .text("email_address_column", self.email_address_column.to_string()); - - if let Some(amount) = self.first_name_column { - multipart_form = multipart_form.text("first_name_column", amount.to_string()); - } - if let Some(amount) = self.last_name_column { - multipart_form = multipart_form.text("last_name_column", amount.to_string()); - } - if let Some(amount) = self.gender_column { - multipart_form = multipart_form.text("gender_column", amount.to_string()); - } - if let Some(amount) = self.ip_address_column { - multipart_form = multipart_form.text("ip_address_column", amount.to_string()); + .text( + "email_address_column", + self.email_address_column.to_string(), + ); + + if include_person_columns { + if let Some(amount) = self.first_name_column { + multipart_form = multipart_form.text("first_name_column", amount.to_string()); + } + if let Some(amount) = self.last_name_column { + multipart_form = multipart_form.text("last_name_column", amount.to_string()); + } + if let Some(amount) = self.gender_column { + multipart_form = multipart_form.text("gender_column", amount.to_string()); + } + if let Some(amount) = self.ip_address_column { + multipart_form = multipart_form.text("ip_address_column", amount.to_string()); + } } if let Some(url) = &self.return_url { multipart_form = multipart_form.text("return_url", url.clone()); @@ -254,20 +258,17 @@ impl ZBFile { } } - #[cfg(test)] - mod test { - use chrono::{NaiveDate, NaiveTime, NaiveDateTime}; - use serde_json::{Result as SerdeResult, from_str}; + use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; + use serde_json::{from_str, Result as SerdeResult}; use super::*; - use crate::utility::mock_constants::BULK_VALIDATION_SUBMIT_OK; - use crate::utility::mock_constants::BULK_VALIDATION_STATUS_OK; - use crate::utility::mock_constants::BULK_VALIDATION_STATUS_DELETED; - use crate::utility::mock_constants::BULK_VALIDATION_RESULT_DELETED; use crate::utility::mock_constants::BULK_VALIDATION_DELETE_OK; - + use crate::utility::mock_constants::BULK_VALIDATION_RESULT_DELETED; + use crate::utility::mock_constants::BULK_VALIDATION_STATUS_DELETED; + use crate::utility::mock_constants::BULK_VALIDATION_STATUS_OK; + use crate::utility::mock_constants::BULK_VALIDATION_SUBMIT_OK; #[test] fn test_parsing_file_submit_response_ok() { @@ -275,7 +276,7 @@ mod test { assert!(validation.is_ok()); let validation_obj = validation.unwrap(); - assert_eq!(validation_obj.success, true); + assert!(validation_obj.success); assert!(validation_obj.file_id.is_some()); assert!(validation_obj.file_name.is_some()); assert_eq!(validation_obj.message, "File Accepted"); @@ -292,7 +293,7 @@ mod test { ); let file_status_obj = file_status.unwrap(); - assert_eq!(file_status_obj.success, true); + assert!(file_status_obj.success); assert_eq!(file_status_obj.complete_percentage, 100.); assert_eq!(file_status_obj.upload_date.naive_utc(), expected_date_time); assert!(file_status_obj.return_url.is_some()); @@ -310,12 +311,11 @@ mod test { ); let file_status_obj = file_status.unwrap(); - assert_eq!(file_status_obj.success, true); + assert!(file_status_obj.success); assert_eq!(file_status_obj.complete_percentage, 0.); assert_eq!(file_status_obj.upload_date.naive_utc(), expected_date_time); assert!(file_status_obj.return_url.is_none()); assert!(file_status_obj.error_reason.is_some()); - } #[test] @@ -324,7 +324,7 @@ mod test { assert!(feedback.is_ok()); let feedback_obj = feedback.unwrap(); - assert_eq!(feedback_obj.success, false); + assert!(!(feedback_obj.success)); assert!(feedback_obj.file_id.is_none()); assert!(feedback_obj.file_name.is_none()); } @@ -335,10 +335,9 @@ mod test { assert!(feedback.is_ok()); let feedback_obj = feedback.unwrap(); - assert_eq!(feedback_obj.success, true); + assert!(feedback_obj.success); assert!(feedback_obj.file_id.is_some()); assert!(feedback_obj.file_name.is_some()); assert_eq!(feedback_obj.message, "File Deleted"); } - } diff --git a/src/utility/structures/generic.rs b/src/utility/structures/generic.rs index 3f765a4..f95b1eb 100644 --- a/src/utility/structures/generic.rs +++ b/src/utility/structures/generic.rs @@ -5,42 +5,87 @@ use crate::utility::structures::custom_deserialize::deserialize_only_date; use crate::utility::structures::custom_deserialize::deserialize_stringified_uint; #[derive(Clone, Debug, Deserialize)] +/// LIVE: the API returns only the counters that apply to the account and the +/// date range asked for — `sub_status_gold` and +/// `sub_status_role_based_accept_all` are absent from an ordinary response. +/// Deserializing them as required fields failed the whole call, so every +/// counter defaults to 0 and a counter added later is ignored rather than +/// fatal. pub struct ApiUsage { + #[serde(default)] pub total: u64, + #[serde(default)] pub status_valid: u64, + #[serde(default)] pub status_invalid: u64, + #[serde(default)] pub status_catch_all: u64, + #[serde(default)] pub status_do_not_mail: u64, + #[serde(default)] pub status_spamtrap: u64, + #[serde(default)] pub status_unknown: u64, + #[serde(default)] pub sub_status_toxic: u64, + #[serde(default)] pub sub_status_disposable: u64, + #[serde(default)] pub sub_status_role_based: u64, + #[serde(default)] pub sub_status_possible_trap: u64, + #[serde(default)] pub sub_status_global_suppression: u64, + #[serde(default)] pub sub_status_timeout_exceeded: u64, + #[serde(default)] pub sub_status_mail_server_temporary_error: u64, + #[serde(default)] pub sub_status_mail_server_did_not_respond: u64, + #[serde(default)] pub sub_status_greylisted: u64, + #[serde(default)] pub sub_status_antispam_system: u64, + #[serde(default)] pub sub_status_does_not_accept_mail: u64, + #[serde(default)] pub sub_status_exception_occurred: u64, + #[serde(default)] pub sub_status_failed_syntax_check: u64, + #[serde(default)] pub sub_status_mailbox_not_found: u64, + #[serde(default)] pub sub_status_unroutable_ip_address: u64, + #[serde(default)] pub sub_status_possible_typo: u64, + #[serde(default)] pub sub_status_no_dns_entries: u64, + #[serde(default)] pub sub_status_role_based_catch_all: u64, + #[serde(default)] pub sub_status_accept_all: u64, + #[serde(default)] pub sub_status_mailbox_quota_exceeded: u64, + #[serde(default)] pub sub_status_forcible_disconnect: u64, + #[serde(default)] pub sub_status_failed_smtp_connection: u64, + #[serde(default)] pub sub_status_mx_forward: u64, + #[serde(default)] pub sub_status_alternate: u64, + #[serde(default)] pub sub_status_allowed: u64, + #[serde(default)] pub sub_status_blocked: u64, + #[serde(default)] pub sub_status_gold: u64, + #[serde(default)] pub sub_status_role_based_accept_all: u64, + #[serde(default)] + pub sub_status_leading_period_removed: u64, + #[serde(default)] + pub sub_status_alias_address: u64, #[serde(deserialize_with="deserialize_only_date")] pub start_date: NaiveDate, @@ -57,6 +102,12 @@ pub struct ActivityData { pub active_in_days: Option, } +#[derive(Clone, Debug, Deserialize)] +pub struct ScoreEmailResponse { + pub email: String, + pub score: i64, +} + #[derive(Clone, Debug, Deserialize)] pub struct DomainFormats { pub format: String, diff --git a/src/utility/structures/mod.rs b/src/utility/structures/mod.rs index 10dd7ef..3267c0f 100644 --- a/src/utility/structures/mod.rs +++ b/src/utility/structures/mod.rs @@ -2,6 +2,7 @@ pub mod bulk; pub mod custom_deserialize; pub mod generic; pub mod validate_enums; +pub mod validate_options; pub mod validation; pub use generic::{ActivityData, ApiUsage, FindEmailResponse, FindEmailResponseV2, DomainSearchResponseV2}; diff --git a/src/utility/structures/validate_enums.rs b/src/utility/structures/validate_enums.rs index 1c6c061..4f0003a 100644 --- a/src/utility/structures/validate_enums.rs +++ b/src/utility/structures/validate_enums.rs @@ -72,6 +72,7 @@ pub enum ZBValidateSubStatus { AcceptAll, RoleBasedAcceptAll, Gold, + AiAgentMailbox, /// Unknown or future API value (backward compatible) UnknownValue(String), } @@ -112,6 +113,7 @@ impl FromStr for ZBValidateSubStatus { "accept_all" => ZBValidateSubStatus::AcceptAll, "role_based_accept_all" => ZBValidateSubStatus::RoleBasedAcceptAll, "gold" => ZBValidateSubStatus::Gold, + "ai_agent_mailbox" => ZBValidateSubStatus::AiAgentMailbox, other => ZBValidateSubStatus::UnknownValue(other.to_string()), }) } diff --git a/src/utility/structures/validate_options.rs b/src/utility/structures/validate_options.rs new file mode 100644 index 0000000..b57a02d --- /dev/null +++ b/src/utility/structures/validate_options.rs @@ -0,0 +1,13 @@ +#[derive(Clone, Debug, Default)] +pub struct ZBValidateOptions { + pub timeout: Option, + pub activity_data: Option, + pub verify_plus: Option, + /// Adds `domain_website_exists` and `domain_registrant_company_name` to the response. + pub domain_info: Option, + /// Processes a greylisted address again instead of returning it as unknown. + pub greylist_processing: Option, + /// Where the result is sent once ready. Only valid with `greylist_processing: Some(true)`. + pub callback_url: Option, + pub use_post: bool, +} diff --git a/src/utility/structures/validation.rs b/src/utility/structures/validation.rs index 62b76ca..fd4a01c 100644 --- a/src/utility/structures/validation.rs +++ b/src/utility/structures/validation.rs @@ -10,11 +10,14 @@ pub struct ZBValidation { pub address: String, pub status: String, pub sub_status: String, + #[serde(default)] pub free_email: bool, pub did_you_mean: Option, pub account: Option, pub domain: Option, pub domain_age_days: Option, + pub active_in_days: Option, + pub active_first_seen: Option, pub smtp_provider: Option, pub catchall_domain: Option, pub mx_record: Option, @@ -27,7 +30,21 @@ pub struct ZBValidation { pub city: Option, pub zipcode: Option, - #[serde(deserialize_with="deserialize_naive_date")] + /// With `domain_info`: whether the domain has a website ("True", "False" or "unchecked"). + pub domain_website_exists: Option, + + /// With `domain_info`: the WHOIS registrant company, "Registrant info not found" + /// or "unchecked". + pub domain_registrant_company_name: Option, + + /// Set instead of a result while greylist processing is still running; validate + /// again later to pick it up. The API spells this key "Message". + #[serde(rename = "Message")] + pub message: Option, + + /// A greylist pickup that is still running carries no timestamp, so this + /// defaults rather than failing the whole response. + #[serde(default, deserialize_with="deserialize_naive_date")] pub processed_at: NaiveDateTime, } diff --git a/src/warmup.rs b/src/warmup.rs new file mode 100644 index 0000000..325cf90 --- /dev/null +++ b/src/warmup.rs @@ -0,0 +1,690 @@ +//! Client for the ZeroBounce Warmup API (). +//! +//! The Warmup API is separate from the email validation API: its own host, its +//! own team-scoped key, and an `API-Key` header instead of an `api_key` +//! parameter. Use [`ZeroBounceWarmup`] with a Warmup API key; [`crate::ZeroBounce`] +//! stays for the validation API key. + +use std::collections::HashMap; +use std::time::Duration; + +use chrono::NaiveDate; +use serde_json::{Map, Value}; + +use crate::utility::http_error::message_from_body; +use crate::utility::{ZBError, ZBResult}; + +const DEFAULT_BASE_URL: &str = "https://warmup-api.zerobounce.net"; +const BASE_PATH: &str = "/api/v1/warmup"; + +/// Required by the schema but always replaced with the key's own team. +const ANY_TEAM_ID: i64 = 1; + +/// How a sending account is run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnboardingModel { + /// You run the mailbox yourself. + SelfManaged, + /// ZeroBounce provisions and runs the mailbox. + ZbManaged, +} + +impl OnboardingModel { + fn as_str(self) -> &'static str { + match self { + OnboardingModel::SelfManaged => "self_managed", + OnboardingModel::ZbManaged => "zb_managed", + } + } +} + +/// What ZeroBounce should send on your behalf, for `zb_managed` onboarding. +#[derive(Debug, Clone, Default)] +pub struct EmailContent { + /// Any of `transactional`, `newsletter`, `marketing`. + pub types_of_emails: Vec, + pub industry: Option, + pub company_name: Option, + pub website: Option, +} + +/// When ZeroBounce should send, for `zb_managed` onboarding. +#[derive(Debug, Clone, Default)] +pub struct ManagedSending { + /// -11 to 14. + pub timezone_offset: Option, + pub warmup_start_date: Option, + pub daily_limit: Option, +} + +const EMAIL_TYPES: [&str; 3] = ["transactional", "newsletter", "marketing"]; + +#[derive(Debug)] +pub struct ZeroBounceWarmup { + api_key: String, + root: String, + client: reqwest::blocking::Client, +} + +impl ZeroBounceWarmup { + /// A client for the default Warmup host. + pub fn new(api_key: &str) -> ZBResult { + Self::with_base_url(api_key, DEFAULT_BASE_URL) + } + + /// A client for a custom https base URL. + pub fn with_base_url(api_key: &str, base_url: &str) -> ZBResult { + if api_key.trim().is_empty() { + return Err(ZBError::explicit("api_key is required")); + } + // A key with surrounding whitespace or control characters would be + // quoted, unredacted, in the HTTP library's own error. + if api_key != api_key.trim() || api_key.chars().any(|c| c.is_control()) { + return Err(ZBError::explicit( + "api_key has surrounding whitespace or control characters", + )); + } + // https only, with the same loopback exception the validation client + // makes so the offline suite can point at a local fake. + crate::parse_request_url(base_url) + .map_err(|_| ZBError::explicit("base_url must be an https:// URL"))?; + Ok(ZeroBounceWarmup { + api_key: api_key.to_string(), + root: format!("{}{BASE_PATH}", base_url.trim_end_matches('/')), + client: reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap_or_else(|_| reqwest::blocking::Client::default()), + }) + } + + // -- transport ----------------------------------------------------------- + + /// Replace every form of a secret in a message with `***`. + fn redact(&self, text: &str, secrets: &[&str]) -> String { + let mut value = text.to_string(); + for secret in std::iter::once(self.api_key.as_str()).chain(secrets.iter().copied()) { + if secret.is_empty() { + continue; + } + value = value.replace(secret, "***"); + } + value + } + + fn send( + &self, + method: &str, + path: &str, + query: &HashMap<&str, String>, + body: Option<&Value>, + secrets: &[&str], + ) -> ZBResult<(u16, String, String)> { + let url = crate::parse_request_url(&format!("{}{path}", self.root))?; + let mut request = match method { + "POST" => self.client.post(url), + "PUT" => self.client.put(url), + "DELETE" => self.client.delete(url), + _ => self.client.get(url), + } + .header("API-Key", self.api_key.as_str()) + .header(reqwest::header::ACCEPT, "application/json") + .query(query); + + if let Some(body) = body { + // reqwest's json() helper needs its "json" feature, which this + // crate does not enable, so the body and content type are set here. + let encoded = serde_json::to_string(body) + .map_err(|_| ZBError::explicit("Invalid parameter: request body"))?; + request = request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(encoded); + } + + let response = request + .send() + .map_err(|error| ZBError::ExplicitError(self.redact(&error.to_string(), secrets)))?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + let text = response + .text() + .map_err(|error| ZBError::ExplicitError(self.redact(&error.to_string(), secrets)))?; + + if (300..400).contains(&status) { + return Err(ZBError::api( + status, + "ZeroBounce returned a redirect, which this client does not follow.", + )); + } + if status > 299 { + return Err(ZBError::api( + status, + self.redact(&message_from_body(status, &content_type, &text), secrets), + )); + } + Ok((status, content_type, text)) + } + + /// The JSON payload of a success, or `None` for the documented empty + /// successes (204, and an empty 200). + fn json( + &self, + method: &str, + path: &str, + query: &HashMap<&str, String>, + body: Option<&Value>, + secrets: &[&str], + ) -> ZBResult> { + let (status, content_type, text) = self.send(method, path, query, body, secrets)?; + if status == 204 || text.trim().is_empty() { + return Ok(None); + } + match serde_json::from_str::(&text) { + Ok(value) => Ok(Some(value)), + Err(_) => Err(ZBError::api( + status, + self.redact(&message_from_body(status, &content_type, &text), secrets), + )), + } + } + + fn get(&self, path: &str, query: &HashMap<&str, String>) -> ZBResult> { + self.json("GET", path, query, None, &[]) + } + + fn write(&self, method: &str, path: &str, body: Value) -> ZBResult> { + self.json(method, path, &HashMap::new(), Some(&body), &[]) + } + + /// A CSV download, returned as text. + fn csv(&self, path: &str, query: &HashMap<&str, String>) -> ZBResult { + let (status, content_type, text) = self.send("GET", path, query, None, &[])?; + if text.trim().is_empty() || content_type.to_ascii_lowercase().contains("application/json") + { + return Err(ZBError::api( + status, + message_from_body(status, &content_type, &text), + )); + } + Ok(text) + } + + // -- argument checks ----------------------------------------------------- + + fn required<'a>(value: &'a str, name: &str) -> ZBResult<&'a str> { + if value.trim().is_empty() { + return Err(ZBError::ExplicitError(format!("{name} is required"))); + } + Ok(value) + } + + fn in_range(value: i64, low: i64, high: i64, name: &str) -> ZBResult { + if !(low..=high).contains(&value) { + return Err(ZBError::ExplicitError(format!( + "{name} must be {low} to {high}" + ))); + } + Ok(value) + } + + fn engagement_body( + c_from: &str, + reply_pct: i64, + important_pct: i64, + click_pct: i64, + ) -> ZBResult { + Self::required(c_from, "c_from")?; + Self::in_range(reply_pct, 5, 45, "reply_pct")?; + Self::in_range(important_pct, 5, 40, "important_pct")?; + Self::in_range(click_pct, 0, 70, "click_pct")?; + if (click_pct as f64) > important_pct as f64 * 1.75 { + return Err(ZBError::explicit( + "click_pct must be at most important_pct x 1.75", + )); + } + Ok(Value::Object(Map::from_iter([ + ("c_from".into(), Value::String(c_from.to_string())), + ("reply_pct".into(), Value::from(reply_pct)), + ("important_pct".into(), Value::from(important_pct)), + ("click_pct".into(), Value::from(click_pct)), + ]))) + } + + // -- sending accounts ---------------------------------------------------- + + /// Every sending account of the key's team. `GET /sending-accounts` + pub fn list_sending_accounts(&self) -> ZBResult> { + self.get("/sending-accounts", &HashMap::new()) + } + + /// One sending account. `GET /sending-account` + pub fn get_sending_account(&self, from_address: &str) -> ZBResult> { + Self::required(from_address, "from_address")?; + self.get( + "/sending-account", + &HashMap::from([("from_address", from_address.to_string())]), + ) + } + + /// Add a from-address to warmup. `POST /sending-accounts` + /// + /// `SelfManaged` runs your own mailbox and needs `engagement_rule_id` 0-4. + /// `ZbManaged` has ZeroBounce provision it and takes `email_content` and + /// `managed_sending`; a 200 means accepted, and the mailbox is provisioned + /// afterwards. + pub fn add_sending_account( + &self, + from_address: &str, + onboarding_model: OnboardingModel, + engagement_rule_id: Option, + ignore_conflict: Option, + email_content: Option<&EmailContent>, + managed_sending: Option<&ManagedSending>, + ) -> ZBResult> { + Self::required(from_address, "from_address")?; + let mut body = Map::from_iter([ + ("from_address".into(), Value::String(from_address.into())), + ( + "onboarding_model".into(), + Value::String(onboarding_model.as_str().into()), + ), + ]); + + if onboarding_model == OnboardingModel::SelfManaged { + let Some(rule) = engagement_rule_id else { + return Err(ZBError::explicit( + "engagement_rule_id is required for self_managed", + )); + }; + body.insert( + "engagement_rule_id".into(), + Value::from(Self::in_range(rule, 0, 4, "engagement_rule_id")?), + ); + if email_content.is_some() || managed_sending.is_some() { + return Err(ZBError::explicit( + "email_content and managed_sending are for zb_managed", + )); + } + } + if let Some(flag) = ignore_conflict { + body.insert("ignore_conflict".into(), Value::Bool(flag)); + } + if let Some(content) = email_content { + let mut object = Map::new(); + if !content.types_of_emails.is_empty() { + for kind in &content.types_of_emails { + if !EMAIL_TYPES.contains(&kind.as_str()) { + return Err(ZBError::explicit( + "types_of_emails must be transactional, newsletter and/or marketing", + )); + } + } + object.insert( + "types_of_emails".into(), + Value::Array( + content + .types_of_emails + .iter() + .map(|kind| Value::String(kind.clone())) + .collect(), + ), + ); + } + for (key, value) in [ + ("industry", &content.industry), + ("company_name", &content.company_name), + ("website", &content.website), + ] { + if let Some(value) = value { + object.insert(key.into(), Value::String(value.clone())); + } + } + body.insert("email_content".into(), Value::Object(object)); + } + if let Some(sending) = managed_sending { + let mut object = Map::new(); + if let Some(offset) = sending.timezone_offset { + Self::in_range(offset as i64, -11, 14, "timezone_offset")?; + object.insert("timezone_offset".into(), Value::from(offset)); + } + if let Some(date) = sending.warmup_start_date { + object.insert( + "warmup_start_date".into(), + Value::String(date.format("%F").to_string()), + ); + } + if let Some(limit) = sending.daily_limit { + object.insert("daily_limit".into(), Value::from(limit)); + } + body.insert("managed_sending".into(), Value::Object(object)); + } + self.write("POST", "/sending-accounts", Value::Object(body)) + } + + /// Change an account's engagement rule, status and auto-enable flag. + /// `PUT /sending-accounts`. `status_id` is 0 to 8 (4 Paused, 5 Disabled). + pub fn update_sending_account( + &self, + from_address: &str, + engagement_rule_id: i64, + status_id: i64, + auto_enable: bool, + ) -> ZBResult> { + Self::required(from_address, "from_address")?; + let body = Value::Object(Map::from_iter([ + ("team_id".into(), Value::from(ANY_TEAM_ID)), + ("from_address".into(), Value::String(from_address.into())), + ( + "engagement_rule_id".into(), + Value::from(Self::in_range(engagement_rule_id, 0, 4, "engagement_rule_id")?), + ), + ( + "status_id".into(), + Value::from(Self::in_range(status_id, 0, 8, "status_id")?), + ), + ("auto_enable".into(), Value::Bool(auto_enable)), + ])); + self.write("PUT", "/sending-accounts", body) + } + + /// Remove a sending account. `DELETE /sending-accounts`. Destructive and final. + pub fn delete_sending_account(&self, from_address: &str) -> ZBResult> { + Self::required(from_address, "from_address")?; + self.json( + "DELETE", + "/sending-accounts", + &HashMap::from([("from_address", from_address.to_string())]), + None, + &[], + ) + } + + /// Move an account and its history to a new from-address. + /// `PUT /smtp-credentials` — despite the path, no SMTP credentials are stored. + pub fn change_from_address( + &self, + old_from_address: &str, + new_from_address: &str, + ) -> ZBResult> { + Self::required(old_from_address, "old_from_address")?; + Self::required(new_from_address, "new_from_address")?; + let body = Value::Object(Map::from_iter([ + ("team_id".into(), Value::from(ANY_TEAM_ID)), + ( + "old_from_address".into(), + Value::String(old_from_address.into()), + ), + ( + "new_from_address".into(), + Value::String(new_from_address.into()), + ), + ])); + self.write("PUT", "/smtp-credentials", body) + } + + /// Registered from-addresses. `GET /from-addresses` + /// + /// `search_from` takes letters, digits and `. - _ @ +` only. `offset` and + /// `limit` go on the wire as strings. + pub fn list_from_addresses( + &self, + search_from: Option<&str>, + offset: Option, + limit: Option, + ) -> ZBResult> { + let mut query = HashMap::new(); + if let Some(search) = search_from { + if !search + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".-_@+".contains(c)) + { + return Err(ZBError::explicit( + "search_from takes letters, digits and . - _ @ + only", + )); + } + query.insert("search_from", search.to_string()); + } + if let Some(offset) = offset { + query.insert("offset", offset.to_string()); + } + if let Some(limit) = limit { + query.insert("limit", limit.to_string()); + } + self.get("/from-addresses", &query) + } + + // -- engagement rules ---------------------------------------------------- + + /// -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. + pub fn get_engagement_rules(&self) -> ZBResult> { + self.get("/engagement-rules", &HashMap::new()) + } + + /// Set a standard rule (0 to 4) on an address. `PUT /engagement-rule` + pub fn set_engagement_rule(&self, c_from: &str, rule_id: i64) -> ZBResult> { + Self::required(c_from, "c_from")?; + let body = Value::Object(Map::from_iter([( + "rule_id".to_string(), + Value::from(Self::in_range(rule_id, 0, 4, "rule_id")?), + )])); + self.json( + "PUT", + "/engagement-rule", + &HashMap::from([("c_from", c_from.to_string())]), + Some(&body), + &[], + ) + } + + /// An address's custom rule, with `can_update` and `remaining_seconds`. + pub fn get_custom_engagement(&self, c_from: &str) -> ZBResult> { + Self::required(c_from, "c_from")?; + let path = format!( + "/custom-engagement/{}", + crate::utility::encode_path_segment(c_from.trim()) + ); + if c_from.trim() == "." || c_from.trim() == ".." { + return Err(ZBError::explicit("c_from is invalid")); + } + self.get(&path, &HashMap::new()) + } + + /// Create a custom rule. Starts a 7 day cooldown on updates. + pub fn create_custom_engagement( + &self, + c_from: &str, + reply_pct: i64, + important_pct: i64, + click_pct: i64, + ) -> ZBResult> { + let body = Self::engagement_body(c_from, reply_pct, important_pct, click_pct)?; + self.write("POST", "/custom-engagement", body) + } + + /// Update a custom rule. During the 7 day cooldown the API answers 429. + pub fn update_custom_engagement( + &self, + c_from: &str, + reply_pct: i64, + important_pct: i64, + click_pct: i64, + ) -> ZBResult> { + let body = Self::engagement_body(c_from, reply_pct, important_pct, click_pct)?; + self.write("PUT", "/custom-engagement", body) + } + + /// Delete a custom rule and fall back to standard rule 1-4. + pub fn delete_custom_engagement( + &self, + c_from: &str, + engagement_rule_id: i64, + ) -> ZBResult> { + Self::required(c_from, "c_from")?; + let body = Value::Object(Map::from_iter([ + ("c_from".to_string(), Value::String(c_from.into())), + ( + "engagement_rule_id".to_string(), + Value::from(Self::in_range(engagement_rule_id, 1, 4, "engagement_rule_id")?), + ), + ])); + self.write("DELETE", "/custom-engagement", body) + } + + // -- statistics, seeds and utilities ------------------------------------- + + /// The seed list. `GET /seeds` + pub fn get_seeds(&self) -> ZBResult> { + self.get("/seeds", &HashMap::new()) + } + + /// The seed list as CSV (columns `seed,provider`). `GET /seeds/download` + pub fn download_seeds(&self) -> ZBResult { + self.csv("/seeds/download", &HashMap::new()) + } + + /// Number of seeds. `GET /seed-count` + pub fn get_seed_count(&self) -> ZBResult> { + self.get("/seed-count", &HashMap::new()) + } + + /// Today's warmup progress. `GET /progress-today` + pub fn get_progress_today(&self) -> ZBResult> { + self.get("/progress-today", &HashMap::new()) + } + + /// Messages sent today. `GET /sent-today` + pub fn get_sent_today(&self) -> ZBResult> { + self.get("/sent-today", &HashMap::new()) + } + + /// Current daily volume. `GET /emails-per-day` + pub fn get_emails_per_day(&self) -> ZBResult> { + self.get("/emails-per-day", &HashMap::new()) + } + + /// Statistics for an address. An address with no history returns zeros. + pub fn get_statistics(&self, from_address: &str) -> ZBResult> { + Self::required(from_address, "from_address")?; + self.get( + "/statistics", + &HashMap::from([("from_address", from_address.to_string())]), + ) + } + + /// Daily statistics between two dates, inclusive. `GET /statistics-per-day` + pub fn get_statistics_per_day( + &self, + from_address: &str, + start_date: NaiveDate, + end_date: NaiveDate, + ) -> ZBResult> { + Self::required(from_address, "from_address")?; + if start_date > end_date { + return Err(ZBError::explicit("start_date is later than end_date")); + } + self.get( + "/statistics-per-day", + &HashMap::from([ + ("from_address", from_address.to_string()), + ("start_date", start_date.format("%F").to_string()), + ("end_date", end_date.format("%F").to_string()), + ]), + ) + } + + /// Detailed statistics for an address. `GET /statistics-detailed` + pub fn get_statistics_detailed(&self, c_from: &str) -> ZBResult> { + Self::required(c_from, "c_from")?; + self.get( + "/statistics-detailed", + &HashMap::from([("c_from", c_from.to_string())]), + ) + } + + /// An address's statistics as CSV. `GET /statistics/download` + pub fn download_statistics(&self, c_from: &str) -> ZBResult { + Self::required(c_from, "c_from")?; + self.csv( + "/statistics/download", + &HashMap::from([("c_from", c_from.to_string())]), + ) + } + + /// Statistics for one from-address. `GET /from-address-statistics` + pub fn get_from_address_statistics(&self, from_address: &str) -> ZBResult> { + Self::required(from_address, "from_address")?; + self.get( + "/from-address-statistics", + &HashMap::from([("from_address", from_address.to_string())]), + ) + } + + /// SPF, DKIM and DMARC status of a from-address's domain. + /// `GET /check-domain-dmarc` + /// + /// LIVE: pass an **email address**. The live API answers "Please provide a + /// valid email address" for a bare domain, although the docs describe one. + pub fn check_domain_dmarc( + &self, + from_address: &str, + dkim_selector: Option<&str>, + ) -> ZBResult> { + Self::required(from_address, "from_address")?; + if !from_address.contains('@') { + return Err(ZBError::explicit( + "from_address must be an email address, e.g. sender@example.com", + )); + } + let mut query = HashMap::from([("from_address", from_address.to_string())]); + if let Some(selector) = dkim_selector { + Self::required(selector, "dkim_selector")?; + query.insert("dkim_selector", selector.to_string()); + } + self.get("/check-domain-dmarc", &query) + } + + /// Check that SMTP credentials connect and hand off a message. + /// `POST /test-smtp-configuration` + /// + /// Nothing is stored, and the test message goes to a ZeroBounce test + /// recipient, not `from_address`. + /// + /// LIVE: the API answers any failed SMTP login with HTTP 500. That is an + /// API bug. The username and password are masked in every error from here. + pub fn test_smtp_configuration( + &self, + from_address: &str, + username: &str, + password: &str, + server: &str, + port: u16, + ) -> ZBResult> { + Self::required(from_address, "from_address")?; + Self::required(username, "username")?; + Self::required(password, "password")?; + Self::required(server, "server")?; + if port == 0 { + return Err(ZBError::explicit("port must be 1 to 65535")); + } + let body = Value::Object(Map::from_iter([ + ("from_address".to_string(), Value::String(from_address.into())), + ("username".to_string(), Value::String(username.into())), + ("password".to_string(), Value::String(password.into())), + ("server".to_string(), Value::String(server.into())), + ("port".to_string(), Value::from(port)), + ])); + self.json( + "POST", + "/test-smtp-configuration", + &HashMap::new(), + Some(&body), + &[password, username], + ) + } +} diff --git a/tests/bulk/ai_scoring.rs b/tests/bulk/ai_scoring.rs index 84e4c27..694f7dd 100644 --- a/tests/bulk/ai_scoring.rs +++ b/tests/bulk/ai_scoring.rs @@ -23,9 +23,9 @@ fn test_ai_scoring_submit_invalid_json() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -44,7 +44,7 @@ fn test_ai_scoring_submit_bad_request() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } @@ -79,9 +79,9 @@ fn test_ai_scoring_status_invalid_json() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -99,7 +99,7 @@ fn test_ai_scoring_status_bad_request() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } @@ -217,9 +217,9 @@ fn test_ai_scoring_delete_invalid_json() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -237,7 +237,7 @@ fn test_ai_scoring_delete_bad_request() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } @@ -252,12 +252,15 @@ fn test_ai_scoring_delete_not_ok() { .with_body(mock_constants::BULK_VALIDATION_DELETE_NOT_FOUND) .create(); + // LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The + // API answers an unknown file id that way, with a 200. let response = zb_instance.ai_scoring_result_delete("mock_file_id"); mock.assert(); - assert!(response.is_ok()); - let response_obj = response.unwrap(); - assert_eq!(response_obj.success, false, "{:#?}", response_obj); + let zb_error = response.unwrap_err(); + assert_eq!(zb_error.status_code(), Some(200)); + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); } #[test] diff --git a/tests/bulk/validation.rs b/tests/bulk/validation.rs index 70de2bc..ab49aeb 100644 --- a/tests/bulk/validation.rs +++ b/tests/bulk/validation.rs @@ -23,9 +23,9 @@ fn test_bulk_validate_submit_invalid_json() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -44,7 +44,7 @@ fn test_bulk_validate_submit_bad_request() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } @@ -79,9 +79,9 @@ fn test_bulk_validate_status_invalid_json() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -99,7 +99,7 @@ fn test_bulk_validate_status_bad_request() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } @@ -247,9 +247,9 @@ fn test_bulk_validate_delete_invalid_json() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -267,7 +267,7 @@ fn test_bulk_validate_delete_bad_request() { assert!(response.is_err()); let zb_error = response.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } @@ -282,12 +282,15 @@ fn test_bulk_validate_delete_not_ok() { .with_body(mock_constants::BULK_VALIDATION_DELETE_NOT_FOUND) .create(); + // LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The + // API answers an unknown file id that way, with a 200. let response = zb_instance.bulk_validation_result_delete("mock_file_id"); mock.assert(); - assert!(response.is_ok()); - let response_obj = response.unwrap(); - assert_eq!(response_obj.success, false, "{:#?}", response_obj); + let zb_error = response.unwrap_err(); + assert_eq!(zb_error.status_code(), Some(200)); + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); } #[test] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 002b456..85a5dd2 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -3,7 +3,7 @@ use zero_bounce::{ZBUrlProvider, ZeroBounce, ZBFile}; pub(crate) const EMAIL: &str = "example@example.com"; pub(crate) const MOCK_API_KEY: &str = "mock_api_key"; -pub(crate) const INVALID_URL: &str = "http://255.255.255.255"; +pub(crate) const INVALID_URL: &str = "https://255.255.255.255"; pub(crate) const SANDBOX_IP: &str = "99.110.204.1"; pub(crate) fn instantiate<'s>() -> (ServerGuard, ZeroBounce) { @@ -12,7 +12,10 @@ pub(crate) fn instantiate<'s>() -> (ServerGuard, ZeroBounce) { let mock_url: String = mock_server.url().to_owned(); let mock_url_provider = ZBUrlProvider { - url: mock_url.clone(), bulk_url: mock_url.clone() + url: mock_url.clone(), + bulk_url: mock_url.clone(), + list_evaluator_url: mock_url.clone(), + inbox_placement_url: mock_url.clone(), }; let zb_instance = ZeroBounce { api_key: MOCK_API_KEY.to_string().clone(), @@ -35,6 +38,8 @@ pub(crate) fn invalid_url_zb_instance() -> ZeroBounce { url_provider: ZBUrlProvider { url: INVALID_URL.to_owned(), bulk_url: INVALID_URL.to_owned(), + list_evaluator_url: INVALID_URL.to_owned(), + inbox_placement_url: INVALID_URL.to_owned(), }, } } diff --git a/tests/generic_endpoints/activity_data.rs b/tests/generic_endpoints/activity_data.rs index 8257243..8a44611 100644 --- a/tests/generic_endpoints/activity_data.rs +++ b/tests/generic_endpoints/activity_data.rs @@ -30,9 +30,9 @@ fn test_activity_data_invalid_json() { mock.assert(); let zb_error = activity_data.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -50,7 +50,7 @@ fn test_activity_data_bad_request() { mock.assert(); let zb_error = activity_data.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } diff --git a/tests/generic_endpoints/api_usage.rs b/tests/generic_endpoints/api_usage.rs index 573409c..ff92234 100644 --- a/tests/generic_endpoints/api_usage.rs +++ b/tests/generic_endpoints/api_usage.rs @@ -31,9 +31,9 @@ fn test_api_usage_invalid_json() { mock.assert(); let zb_error = api_usage.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -51,7 +51,7 @@ fn test_api_usage_bad_request() { mock.assert(); let zb_error = api_usage.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } diff --git a/tests/generic_endpoints/domain_search_v2.rs b/tests/generic_endpoints/domain_search_v2.rs index fde7989..048f4f2 100644 --- a/tests/generic_endpoints/domain_search_v2.rs +++ b/tests/generic_endpoints/domain_search_v2.rs @@ -96,9 +96,9 @@ fn test_domain_search_v2_invalid_json() { mock.assert(); let zb_error = result.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -120,7 +120,7 @@ fn test_domain_search_v2_bad_request() { mock.assert(); let zb_error = result.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } diff --git a/tests/generic_endpoints/find_email_v2.rs b/tests/generic_endpoints/find_email_v2.rs index 8047bff..d485b25 100644 --- a/tests/generic_endpoints/find_email_v2.rs +++ b/tests/generic_endpoints/find_email_v2.rs @@ -121,9 +121,9 @@ fn test_find_email_v2_invalid_json() { mock.assert(); let zb_error = result.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -149,7 +149,7 @@ fn test_find_email_v2_bad_request() { mock.assert(); let zb_error = result.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } diff --git a/tests/generic_endpoints/find_mail.rs b/tests/generic_endpoints/find_mail.rs index 6c8ac35..e7b5010 100644 --- a/tests/generic_endpoints/find_mail.rs +++ b/tests/generic_endpoints/find_mail.rs @@ -34,9 +34,9 @@ fn test_find_mail_invalid_json() { mock.assert(); let zb_error = find_mail_res.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -56,7 +56,7 @@ fn test_find_bad_request() { mock.assert(); let zb_error = find_mail_res.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } @@ -137,9 +137,9 @@ fn test_domain_search_invalid_json() { mock.assert(); let zb_error = domain_search_res.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -158,7 +158,7 @@ fn test_domain_search_bad_request() { mock.assert(); let zb_error = domain_search_res.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } diff --git a/tests/generic_endpoints/get_credits.rs b/tests/generic_endpoints/get_credits.rs index 1264ba1..c5e45eb 100644 --- a/tests/generic_endpoints/get_credits.rs +++ b/tests/generic_endpoints/get_credits.rs @@ -29,9 +29,9 @@ fn test_credits_invalid_json() { mock.assert(); let credits_error = credits.unwrap_err(); - let ZBError::JsonError(_) = credits_error else { - panic!("unexpected error: {:#?}", credits_error); - }; + let message = credits_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -49,7 +49,7 @@ fn test_credits_bad_request() { mock.assert(); let credits_error = credits.unwrap_err(); - let ZBError::ExplicitError(_) = credits_error else { + let ZBError::ApiError { .. } = credits_error else { panic!("unexpected error: {:#?}", credits_error); }; } diff --git a/tests/live_api.rs b/tests/live_api.rs new file mode 100644 index 0000000..8f63cfc --- /dev/null +++ b/tests/live_api.rs @@ -0,0 +1,534 @@ +//! Opt-in live API suite: calls every SDK method against the real API to check +//! payloads and response shapes. +//! +//! It is behind the `live` feature, so `cargo test` and CI never compile or run +//! it. Run it explicitly: +//! +//! ```text +//! export ZEROBOUNCE_API_KEY=... # validation API (skipped without it) +//! export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API (skipped without it) +//! cargo test --features live --test live_api -- --nocapture +//! ``` +//! +//! By default only free calls run: sandbox addresses (no credits), reads, and +//! requests expected to fail (unknown file IDs, S3/SFTP without an integration) +//! that still prove the API accepts the payload. The free tier checks that the +//! credit balance did not change. Two flags opt in to more: +//! +//! * `ZEROBOUNCE_LIVE_SPEND=1` spends credits: scoring, activity, email finder, +//! and a bulk round trip whose job is deleted again. +//! * `ZEROBOUNCE_LIVE_CHANGES=1` reversible account changes, each undone in +//! cleanup even when a test fails: a test filter, and a self-managed Warmup +//! sending account. +//! +//! Never called, because the API cannot undo them: creating an Inbox Placement +//! test (it cannot be deleted while running), setting the Inbox Placement +//! webhook (it cannot be cleared), and ZB-managed Warmup onboarding (it starts +//! mailbox provisioning). +//! +//! No key is ever written to a file or printed. Keys come from the environment. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use chrono::{Duration as ChronoDuration, Utc}; +use zero_bounce::api::inbox_placement::ListInboxPlacementTestsOptions; +use zero_bounce::utility::structures::validate_options::ZBValidateOptions; +use zero_bounce::{OnboardingModel, ZBFile, ZeroBounce, ZeroBounceWarmup}; + +/// Well-formed but matching no file: a malformed id can be Cloudflare-blocked +/// instead of answered. +const UNKNOWN_ID: &str = "0d1b7a4e-1111-2222-3333-444455556666"; +const CALLBACK: &str = "https://example.com/zerobounce-sdk-live-test"; +const SANDBOX: &str = "valid@example.com"; + +static PASSED: AtomicUsize = AtomicUsize::new(0); +static FAILED: AtomicUsize = AtomicUsize::new(0); +static SKIPPED: AtomicUsize = AtomicUsize::new(0); + +fn api_key() -> String { + std::env::var("ZEROBOUNCE_API_KEY").unwrap_or_default().trim().to_string() +} + +fn warmup_key() -> String { + std::env::var("ZEROBOUNCE_WARMUP_API_KEY").unwrap_or_default().trim().to_string() +} + +fn flag(name: &str) -> bool { + std::env::var(name).map(|value| value == "1").unwrap_or(false) +} + +fn redact(text: &str) -> String { + let mut value = text.to_string(); + for secret in [api_key(), warmup_key()] { + if !secret.is_empty() { + value = value.replace(&secret, "***"); + } + } + value +} + +fn check(name: &str, result: Result<(), String>) { + match result { + Ok(()) => { + PASSED.fetch_add(1, Ordering::Relaxed); + println!(" + {name}"); + } + Err(message) => { + FAILED.fetch_add(1, Ordering::Relaxed); + println!(" X {name}\n {}", redact(&message)); + } + } +} + +fn skip(name: &str, reason: &str) { + SKIPPED.fetch_add(1, Ordering::Relaxed); + println!(" - SKIP {name} ({reason})"); +} + +/// Run `call`, expecting it to fail, and hand back the (redacted) message. +fn expect_failure(what: &str, call: impl FnOnce() -> zero_bounce::ZBResult) -> Result { + match call() { + Ok(_) => Err(format!("{what} unexpectedly succeeded")), + Err(error) => Ok(redact(&error.to_string())), + } +} + +fn sdk() -> ZeroBounce { + ZeroBounce::new(&api_key()) +} + +#[test] +fn live_api_suite() { + let key = api_key(); + let warmup = warmup_key(); + let spend = flag("ZEROBOUNCE_LIVE_SPEND"); + let changes = flag("ZEROBOUNCE_LIVE_CHANGES"); + + println!("ZeroBounce live API suite"); + println!(" validation key: {}", if key.is_empty() { "MISSING" } else { "set" }); + println!(" warmup key: {}", if warmup.is_empty() { "MISSING" } else { "set" }); + println!( + " flags: SPEND={} CHANGES={}\n", + spend as u8, changes as u8 + ); + + if !key.is_empty() { + validation_free(); + if spend { + validation_spend(); + } + if changes { + validation_changes(); + } + } + if !warmup.is_empty() { + warmup_free(); + if changes { + warmup_changes(); + } + } + + let failed = FAILED.load(Ordering::Relaxed); + println!( + "\n{} passed, {failed} failed, {} skipped", + PASSED.load(Ordering::Relaxed), + SKIPPED.load(Ordering::Relaxed) + ); + assert_eq!(failed, 0, "{failed} live checks failed"); +} + +// -- validation API, free ----------------------------------------------------- + +fn validation_free() { + println!("Validation API (free calls)"); + let zb = sdk(); + let before = zb.get_credits().unwrap_or(-2); + + check( + "get_credits returns a balance", + if before >= -1 { + Ok(()) + } else { + Err("could not read the credit balance".into()) + }, + ); + + check("get_api_usage parses the documented date formats", || -> Result<(), String> { + let end = Utc::now().date_naive(); + let start = end - ChronoDuration::days(7); + zb.get_api_usage(start, end).map_err(|e| e.to_string())?; + Ok(()) + }()); + + check("validate_email on a sandbox address spends nothing", || -> Result<(), String> { + let response = zb.validate_email(SANDBOX).map_err(|e| e.to_string())?; + if response.address != SANDBOX { + return Err(format!("address did not echo: {}", response.address)); + } + Ok(()) + }()); + + check("validate_email works over POST as well as GET", || -> Result<(), String> { + // No billable add-on here: activity_data, verify_plus and domain_info + // are charged even on a sandbox address, so they live in the SPEND tier. + let options = ZBValidateOptions { + timeout: Some(30), + use_post: true, + ..Default::default() + }; + zb.validate_email_with_options(SANDBOX, "", &options) + .map_err(|e| e.to_string())?; + Ok(()) + }()); + + check("LIVE: validate_batch accepts timeout as a string", || -> Result<(), String> { + // Sent as a JSON number the API rejects the whole batch with the + // account-level "Invalid API Key or your account ran out of credits". + let options = ZBValidateOptions { + timeout: Some(30), + ..Default::default() + }; + let response = zb + .batch_validate_with_options(vec![(SANDBOX.to_string(), String::new())], &options) + .map_err(|e| e.to_string())?; + if response + .errors + .iter() + .any(|error| error.email_address.eq_ignore_ascii_case("all")) + { + return Err(format!("account-level error: {:?}", response.errors)); + } + Ok(()) + }()); + + check("an unknown file id is a short, clean error", || -> Result<(), String> { + let message = expect_failure("bulk_validation_file_status_check", || { + zb.bulk_validation_file_status_check(UNKNOWN_ID) + })?; + if message.len() >= 500 || message.contains(" Result<(), String> { + match zb.list_s3_files(None, None) { + Ok(_) => Ok(()), + // No S3 integration configured is an expected, documented failure. + Err(error) if error.to_string().len() < 500 => Ok(()), + Err(error) => Err(error.to_string()), + } + }()); + + check("LIVE: S3/SFTP jobs reject a payload without return_url before sending", || -> Result<(), String> { + let message = expect_failure("email_finder_s3_file without return_url", || { + zb.email_finder_s3_file("f.csv", true, None, "") + })?; + if !message.contains("return_url") { + return Err(format!("unexpected message: {message}")); + } + Ok(()) + }()); + + check("LIVE: a complete S3 job payload is accepted by the API", || -> Result<(), String> { + // Without an S3 integration this fails on the integration, not the payload. + match zb.email_finder_s3_file("f.csv", true, None, CALLBACK) { + Ok(_) => Ok(()), + Err(error) => { + let message = redact(&error.to_string()); + if message.contains("return URL is not permitted") + || message.contains("This field is required") + { + return Err(format!("payload rejected: {message}")); + } + Ok(()) + } + } + }()); + + check("filters list", zb.list_filters(false).map(|_| ()).map_err(|e| e.to_string())); + + check( + "LIVE: the Inbox Placement Tester accepts a Bearer key", + zb.get_inbox_placement_providers().map(|_| ()).map_err(|e| e.to_string()), + ); + check( + "inbox placement test types", + zb.get_inbox_placement_test_types().map(|_| ()).map_err(|e| e.to_string()), + ); + check("LIVE: inbox placement tests are listed from /tests", || -> Result<(), String> { + // The listing is GET /tests (plural); /test is the single-test path and + // answers "Method \"GET\" not allowed". + let options = ListInboxPlacementTestsOptions { + page: Some(1), + page_size: Some(10), + ..Default::default() + }; + zb.list_inbox_placement_tests(&options).map_err(|e| e.to_string())?; + zb.get_inbox_placement_webhook().map_err(|e| e.to_string())?; + Ok(()) + }()); + + check("list evaluator result for an unknown id fails cleanly", || -> Result<(), String> { + let message = expect_failure("get_list_evaluator_result", || { + zb.get_list_evaluator_result(UNKNOWN_ID) + })?; + if message.len() >= 500 { + return Err(format!("message too long: {}", message.len())); + } + Ok(()) + }()); + + check("LIVE: send_evaluated_file posts the file id as a form field", || -> Result<(), String> { + let message = expect_failure("send_evaluated_file", || zb.send_evaluated_file(UNKNOWN_ID))?; + if message.to_ascii_lowercase().contains("not allowed") { + return Err(format!("wrong method or path: {message}")); + } + Ok(()) + }()); + + check("the free calls did not change the credit balance", || -> Result<(), String> { + let after = sdk().get_credits().map_err(|e| e.to_string())?; + if after != before { + return Err(format!("credits went from {before} to {after}")); + } + Ok(()) + }()); +} + +// -- validation API, spends credits ------------------------------------------- + +fn validation_spend() { + println!("\nValidation API (ZEROBOUNCE_LIVE_SPEND=1)"); + let zb = sdk(); + + check("validate_email accepts every documented option", || -> Result<(), String> { + // LIVE: activity_data, verify_plus and domain_info are billable add-ons + // and are charged even for a sandbox address, so this is not free. + let options = ZBValidateOptions { + timeout: Some(30), + activity_data: Some(true), + verify_plus: Some(true), + domain_info: Some(true), + greylist_processing: Some(true), + callback_url: Some(CALLBACK.to_string()), + use_post: false, + }; + zb.validate_email_with_options(SANDBOX, "", &options) + .map_err(|e| e.to_string())?; + Ok(()) + }()); + + check("score_email", zb.score_email(SANDBOX).map(|_| ()).map_err(|e| e.to_string())); + check("score_email over POST", zb.score_email_post(SANDBOX).map(|_| ()).map_err(|e| e.to_string())); + check("get_activity_data", zb.get_activity_data(SANDBOX).map(|_| ()).map_err(|e| e.to_string())); + check( + "email finder", + zb.find_email_v2() + .first_name("John") + .domain("example.com") + .last_name("Doe") + .call() + .map(|_| ()) + .map_err(|e| e.to_string()), + ); + check( + "domain search", + zb.domain_search_v2().domain("example.com").call().map(|_| ()).map_err(|e| e.to_string()), + ); + + let mut file_id = None; + check("bulk validation round trip: upload, status, delete", || -> Result<(), String> { + let csv = b"email\nvalid@example.com\ninvalid@example.com\n".to_vec(); + let file = ZBFile::from_content_with_filename(csv, "zb-sdk-live.csv"); + let sent = zb.bulk_validation_file_submit(&file).map_err(|e| e.to_string())?; + let Some(id) = sent.file_id.clone() else { + return Err(format!("no file_id in the upload response: {sent:?}")); + }; + let status = zb.bulk_validation_file_status_check(&id).map_err(|e| e.to_string())?; + if status.file_id != id { + return Err("status did not echo the file id".into()); + } + file_id = Some(id); + Ok(()) + }()); + + match file_id { + None => skip( + "the uploaded validation job is deleted again", + "upload did not produce a file id", + ), + Some(id) => check("the uploaded validation job is deleted again", || -> Result<(), String> { + // LIVE: a just-accepted job answers "File cannot be found" for a + // short while, so the delete is retried rather than leaving it behind. + let mut last = String::new(); + for _ in 0..12 { + match zb.bulk_validation_result_delete(&id) { + Ok(_) => return Ok(()), + Err(error) => { + last = redact(&error.to_string()); + std::thread::sleep(Duration::from_secs(10)); + } + } + } + Err(format!("could not delete {id} after retrying: {last}")) + }()), + } +} + +// -- validation API, reversible account changes ------------------------------- + +fn validation_changes() { + println!("\nValidation API (ZEROBOUNCE_LIVE_CHANGES=1)"); + let zb = sdk(); + let value = format!("zb-sdk-live-{}.example.com", Utc::now().timestamp()); + + let added = zb.add_filter("allow", "domain", &value); + check( + "add_filter creates a reversible allow rule", + added.as_ref().map(|_| ()).map_err(|e| e.to_string()), + ); + if added.is_ok() { + check( + "the test filter is removed again", + zb.delete_filter("allow", "domain", &value).map(|_| ()).map_err(|e| e.to_string()), + ); + } else { + skip("the test filter is removed again", "filter was not created"); + } +} + +// -- Warmup API --------------------------------------------------------------- + +fn warmup_client() -> ZeroBounceWarmup { + ZeroBounceWarmup::new(&warmup_key()).expect("warmup key") +} + +fn warmup_free() { + println!("\nWarmup API (reads)"); + let zb = warmup_client(); + + check("list_sending_accounts", zb.list_sending_accounts().map(|_| ()).map_err(|e| e.to_string())); + check("list_from_addresses", zb.list_from_addresses(None, None, None).map(|_| ()).map_err(|e| e.to_string())); + check("get_engagement_rules", zb.get_engagement_rules().map(|_| ()).map_err(|e| e.to_string())); + check("get_seeds", zb.get_seeds().map(|_| ()).map_err(|e| e.to_string())); + check("get_seed_count", zb.get_seed_count().map(|_| ()).map_err(|e| e.to_string())); + check("get_progress_today", zb.get_progress_today().map(|_| ()).map_err(|e| e.to_string())); + check("get_sent_today", zb.get_sent_today().map(|_| ()).map_err(|e| e.to_string())); + check("get_emails_per_day", zb.get_emails_per_day().map(|_| ()).map_err(|e| e.to_string())); + + check("download_seeds returns CSV text", || -> Result<(), String> { + let csv = zb.download_seeds().map_err(|e| e.to_string())?; + if csv.trim().starts_with('{') { + return Err("got JSON instead of CSV".into()); + } + Ok(()) + }()); + + check("LIVE: check_domain_dmarc rejects a bare domain before sending", || -> Result<(), String> { + let message = expect_failure("check_domain_dmarc with a domain", || { + zb.check_domain_dmarc("example.com", None) + })?; + if !message.to_ascii_lowercase().contains("email address") { + return Err(format!("unexpected message: {message}")); + } + Ok(()) + }()); + + check( + "LIVE: check_domain_dmarc accepts an email address", + zb.check_domain_dmarc("sender@example.com", None).map(|_| ()).map_err(|e| e.to_string()), + ); + + // The statistics endpoints need a from-address this team actually has; the + // API answers "Sending account ... was not found" for anything else. + let known = zb.list_sending_accounts().ok().flatten().and_then(|value| { + let list = value + .get("data") + .and_then(|data| data.as_array()) + .cloned() + .or_else(|| value.as_array().cloned())?; + let first = list.first()?.clone(); + ["from_address", "c_from", "fromAddress"] + .iter() + .find_map(|key| first.get(*key).and_then(|v| v.as_str()).map(str::to_string)) + }); + + match known { + None => { + for name in [ + "statistics for a registered from-address", + "statistics per day over a short range", + "detailed statistics and the CSV download for that address", + "from-address statistics", + ] { + skip(name, "this team has no sending account"); + } + } + Some(address) => { + check( + "statistics for a registered from-address", + zb.get_statistics(&address).map(|_| ()).map_err(|e| e.to_string()), + ); + let end = Utc::now().date_naive(); + check( + "statistics per day over a short range", + zb.get_statistics_per_day(&address, end - ChronoDuration::days(6), end) + .map(|_| ()) + .map_err(|e| e.to_string()), + ); + check("detailed statistics and the CSV download for that address", || -> Result<(), String> { + zb.get_statistics_detailed(&address).map_err(|e| e.to_string())?; + zb.download_statistics(&address).map_err(|e| e.to_string())?; + Ok(()) + }()); + check( + "from-address statistics", + zb.get_from_address_statistics(&address).map(|_| ()).map_err(|e| e.to_string()), + ); + } + } + + check("an unknown Warmup resource fails with a status code", || -> Result<(), String> { + match zb.get_custom_engagement("no-such-address@example.com") { + Ok(_) => Ok(()), + Err(error) => { + if error.status_code().is_none() { + return Err("no status code on the error".into()); + } + if error.to_string().contains(&warmup_key()) { + return Err("the key leaked into the message".into()); + } + Ok(()) + } + } + }()); +} + +fn warmup_changes() { + println!("\nWarmup API (ZEROBOUNCE_LIVE_CHANGES=1)"); + let zb = warmup_client(); + let address = format!("zb-sdk-live-{}@example.com", Utc::now().timestamp()); + + let added = zb.add_sending_account( + &address, + OnboardingModel::SelfManaged, + Some(0), + Some(true), + None, + None, + ); + check( + "a self-managed sending account can be added", + added.as_ref().map(|_| ()).map_err(|e| e.to_string()), + ); + if added.is_ok() { + check( + "the test sending account is removed again", + zb.delete_sending_account(&address).map(|_| ()).map_err(|e| e.to_string()), + ); + } else { + skip("the test sending account is removed again", "account was not created"); + } +} diff --git a/tests/offline_api.rs b/tests/offline_api.rs new file mode 100644 index 0000000..9c030d8 --- /dev/null +++ b/tests/offline_api.rs @@ -0,0 +1,1353 @@ +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use chrono::NaiveDate; +use zero_bounce::utility::structures::bulk::{ + zb_download_type, ZBBulkResponse, ZBFile, ZBGetFileOptions, +}; +use zero_bounce::utility::structures::validate_options::ZBValidateOptions; +use zero_bounce::api::inbox_placement::{ + CreateInboxPlacementTestOptions, InboxPlacementProviderSelection, + ListInboxPlacementTestsOptions, +}; +use zero_bounce::utility::BULK_URI; +use zero_bounce::{ApiBaseUrl, ZeroBounce}; + +struct Recorded { + method: String, + path: String, + api_key: String, + #[allow(dead_code)] + authorization: String, + fields: HashMap, +} + +struct Fake { + records: Mutex>, +} + +impl Fake { + fn take(&self) -> Vec { + std::mem::take(&mut *self.records.lock().unwrap()) + } + fn len(&self) -> usize { + self.records.lock().unwrap().len() + } +} + +fn html_page() -> String { + let mut page = String::from("Access denied | zerobounce.net | Zero Bounce"); + for _ in 0..20000 { + page.push_str("

blocked

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

", + "Error: Error:", + "expected", + "serde", + ] { + assert!(!message.contains(forbidden), "{message}"); + } +} + +#[test] +fn version_constant_matches_package() { + assert_eq!( + ApiBaseUrl::Default.as_str(), + "https://api.zerobounce.net/v2/" + ); + assert_eq!(BULK_URI, "https://bulkapi.zerobounce.net/v2"); + assert_eq!(env!("CARGO_PKG_VERSION"), "2.1.3"); +} + +#[test] +fn credits_validate_and_batch_use_the_main_api() { + let (port, fake) = start_fake(); + let zb = client(port, "credits-key"); + assert_eq!(zb.get_credits().unwrap(), 7); + let request = only(&fake); + assert_eq!(request.method, "GET"); + assert!( + request.path.ends_with("/api/v2/getcredits"), + "{}", + request.path + ); + assert_eq!(request.api_key, "credits-key"); + + let mut options = ZBValidateOptions::default(); + options.activity_data = Some(false); + options.verify_plus = Some(true); + let response = zb + .validate_email_with_options("person@example.com", "", &options) + .unwrap(); + assert_eq!(response.address, "credits-key"); + let request = only(&fake); + assert!(request.path.ends_with("/validate"), "{}", request.path); + assert_eq!( + request.fields.get("email").map(String::as_str), + Some("person@example.com") + ); + assert_eq!( + request.fields.get("activity_data").map(String::as_str), + Some("false") + ); + assert_eq!( + request.fields.get("verify_plus").map(String::as_str), + Some("true") + ); + assert!(!request.fields.contains_key("timeout")); + assert!(!request.fields.contains_key("ip_address")); + + let mut posted = ZBValidateOptions::default(); + posted.use_post = true; + posted.timeout = Some(15); + zb.validate_email_with_options("person@example.com", "203.0.113.5", &posted) + .unwrap(); + let request = only(&fake); + assert_eq!(request.method, "POST"); + assert_eq!( + request.fields.get("timeout").map(String::as_str), + Some("15") + ); + assert_eq!( + request.fields.get("ip_address").map(String::as_str), + Some("203.0.113.5") + ); + assert!(!request.fields.contains_key("activity_data")); + + let batch = zb + .batch_validate(vec![ + ("a@example.com".into(), String::new()), + ("b@example.com".into(), "203.0.113.9".into()), + ]) + .unwrap(); + assert_eq!(batch.email_batch[0].address, "credits-key"); + let request = only(&fake); + assert_eq!(request.method, "POST"); + assert!( + request.path.contains("/api/v2/validatebatch"), + "{}", + request.path + ); + assert!(request + .fields + .get("email_batch") + .unwrap() + .contains("a@example.com")); + assert!(!request.fields.contains_key("timeout")); + + let mut batch_options = ZBValidateOptions::default(); + batch_options.timeout = Some(30); + batch_options.activity_data = Some(false); + batch_options.verify_plus = Some(true); + zb.batch_validate_with_options( + vec![("a@example.com".into(), String::new())], + &batch_options, + ) + .unwrap(); + let request = only(&fake); + assert_eq!( + request.fields.get("timeout").map(String::as_str), + Some("30") + ); + assert_eq!( + request.fields.get("activity_data").map(String::as_str), + Some("false") + ); + assert_eq!( + request.fields.get("verify_plus").map(String::as_str), + Some("true") + ); +} + +#[test] +fn files_scoring_and_priority3() { + let (port, fake) = start_fake(); + let zb = client(port, "file-key"); + let file = ZBFile::from_content(b"email\n".to_vec()) + .set_email_address_column(0) + .set_remove_duplicate(false) + .set_has_header_row(true) + .set_allow_phase_2(Some(false)) + .set_return_url(Some("https://example.com/hook")); + let sent = zb.bulk_validation_file_submit(&file).unwrap(); + assert_eq!(sent.file_id.as_deref(), Some("file-key")); + let request = only(&fake); + assert!( + request.path.ends_with("/bulk/v2/sendfile"), + "{}", + request.path + ); + assert_eq!( + request + .fields + .get("email_address_column") + .map(String::as_str), + Some("0") + ); + assert_eq!( + request.fields.get("has_header_row").map(String::as_str), + Some("true") + ); + assert_eq!( + request.fields.get("remove_duplicate").map(String::as_str), + Some("false") + ); + assert_eq!( + request.fields.get("allow_phase_2").map(String::as_str), + Some("false") + ); + assert_eq!( + request.fields.get("file").map(String::as_str), + Some("") + ); + assert!(!request.fields.contains_key("first_name_column")); + + let scoring = file + .set_first_name_column(Some(2)) + .set_allow_phase_2(Some(true)); + zb.ai_scoring_file_submit(&scoring).unwrap(); + let request = only(&fake); + assert!( + request.path.ends_with("/scoring/sendfile"), + "{}", + request.path + ); + assert!(!request.fields.contains_key("allow_phase_2")); + assert!(!request.fields.contains_key("first_name_column")); + + assert_eq!( + zb.bulk_validation_file_status_check("file-1") + .unwrap() + .file_id, + "file-key" + ); + assert!(only(&fake).path.ends_with("/filestatus")); + zb.ai_scoring_file_status_check("file-2").unwrap(); + assert!(only(&fake).path.contains("/scoring/filestatus")); + zb.bulk_validation_result_delete("file-1").unwrap(); + assert!(only(&fake).path.ends_with("/deletefile")); + zb.ai_scoring_result_delete("file-2").unwrap(); + assert!(only(&fake).path.contains("/scoring/deletefile")); + + let options = ZBGetFileOptions { + download_type: Some(zb_download_type::PHASE_2.into()), + activity_data: Some(true), + }; + match zb + .bulk_validation_result_fetch_with_options("file-9", &options) + .unwrap() + { + ZBBulkResponse::Content(bytes) => { + assert!(String::from_utf8_lossy(&bytes).contains("file-key@example.com")) + } + other => panic!("{other:?}"), + } + let request = only(&fake); + assert!(request.path.ends_with("/getfile")); + assert_eq!( + request.fields.get("download_type").map(String::as_str), + Some("phase_2") + ); + assert_eq!( + request.fields.get("activity_data").map(String::as_str), + Some("true") + ); + let scoring_options = ZBGetFileOptions { + download_type: Some(zb_download_type::PHASE_1.into()), + activity_data: Some(true), + }; + zb.ai_scoring_result_fetch_with_options("file-8", &scoring_options) + .unwrap(); + let request = only(&fake); + assert!(request.path.contains("/scoring/getfile")); + assert!(!request.fields.contains_key("activity_data")); + + let start = NaiveDate::from_ymd_opt(2018, 1, 1).unwrap(); + let end = NaiveDate::from_ymd_opt(2018, 1, 31).unwrap(); + assert_eq!(zb.get_api_usage(start, end).unwrap().total, 1); + let request = only(&fake); + assert!(request.path.ends_with("/getapiusage")); + assert_eq!( + request.fields.get("start_date").map(String::as_str), + Some("2018-01-01") + ); + assert!(zb.get_activity_data("person@example.com").unwrap().found); + assert!(only(&fake).path.ends_with("/activity")); + assert_eq!( + zb.score_email("person@example.com").unwrap().email, + "file-key" + ); + assert!(only(&fake).path.ends_with("/scoring")); + zb.score_email_post("person@example.com").unwrap(); + let request = only(&fake); + assert_eq!(request.method, "POST"); + assert!(request.path.ends_with("/scoring")); + + let found = zb + .find_email_v2() + .domain("example.com") + .first_name("Ada") + .middle_name("M") + .last_name("Lovelace") + .call() + .unwrap(); + assert_eq!(found.email, "file-key"); + let request = only(&fake); + assert!(request.path.ends_with("/guessformat"), "{}", request.path); + assert_eq!( + request.fields.get("domain").map(String::as_str), + Some("example.com") + ); + assert!(!request.fields.contains_key("company_name")); + zb.find_email_v2() + .company_name("ZeroBounce") + .first_name("Ada") + .call() + .unwrap(); + let request = only(&fake); + assert_eq!( + request.fields.get("company_name").map(String::as_str), + Some("ZeroBounce") + ); + assert!(!request.fields.contains_key("domain")); + assert!(!request.fields.contains_key("last_name")); + assert_eq!( + zb.domain_search_v2() + .domain("example.com") + .call() + .unwrap() + .domain, + "file-key" + ); + assert!(!only(&fake).fields.contains_key("company_name")); + zb.domain_search_v2() + .company_name("ZeroBounce") + .call() + .unwrap(); + let request = only(&fake); + assert_eq!( + request.fields.get("company_name").map(String::as_str), + Some("ZeroBounce") + ); + assert!(!request.fields.contains_key("domain")); + + zb.send_email_finder_file(1, "finder.csv", &b"example.com\n"[..]) + .unwrap(); + let request = only(&fake); + assert!( + request.path.ends_with("/email-finder/sendfile"), + "{}", + request.path + ); + assert_eq!( + request.fields.get("domain_column").map(String::as_str), + Some("1") + ); + zb.email_finder_file_status("fid").unwrap(); + assert!(only(&fake).path.ends_with("/email-finder/filestatus")); + zb.send_domain_search_file(2, "domains.csv", &b"example.com\n"[..]) + .unwrap(); + let request = only(&fake); + assert!(request.path.ends_with("/domain-search/sendfile")); + assert_eq!( + request.fields.get("domain_column").map(String::as_str), + Some("2") + ); + zb.domain_search_file_status("fid").unwrap(); + assert!(only(&fake).path.ends_with("/domain-search/filestatus")); + zb.send_list_evaluator_file(1, "list.csv", &b"a@example.com\n"[..]) + .unwrap(); + assert!(only(&fake).path.contains("/listevaluator/")); + zb.get_list_evaluator_result("fid").unwrap(); + assert!(only(&fake).path.contains("/listevaluator/fid/")); + // LIVE: send-evaluated-file takes the evaluator's file_id as a form field, + // not a file upload. + zb.send_evaluated_file("eval-file-1").unwrap(); + let request = only(&fake); + assert!(request.path.contains("/send-evaluated-file/")); + assert_eq!(request.method, "POST"); + assert_eq!( + request.fields.get("file_id").map(String::as_str), + Some("eval-file-1") + ); + + zb.add_filter("exclude", "domain", "example.com").unwrap(); + let request = only(&fake); + assert_eq!(request.method, "POST"); + assert!( + request.path.contains("/api/v2/filters/add"), + "{}", + request.path + ); + assert_eq!( + request.fields.get("rule").map(String::as_str), + Some("exclude") + ); + zb.list_filters(false).unwrap(); + assert_eq!(only(&fake).method, "GET"); + zb.list_filters(true).unwrap(); + assert_eq!(only(&fake).method, "POST"); + zb.delete_filter("exclude", "domain", "example.com") + .unwrap(); + assert!(only(&fake).path.ends_with("/filters/delete")); + zb.validate_s3_file("inbox.csv").unwrap(); + assert!(only(&fake).path.ends_with("/validateS3file")); + zb.score_s3_file("inbox.csv").unwrap(); + let request = only(&fake); + assert!(request.path.ends_with("/scoreS3file")); + assert!(!request.fields.contains_key("email_address_column")); + zb.export_s3_file("fid", "bucket-out").unwrap(); + let request = only(&fake); + assert_eq!( + request.fields.get("export_bucket").map(String::as_str), + Some("bucket-out") + ); + let files = zb.list_s3_files(None, None).unwrap(); + assert_eq!(files[0].name, "file-key"); + let request = only(&fake); + assert!(request.path.ends_with("/listS3files")); + assert!(!request.fields.contains_key("bucket")); + zb.list_s3_files(Some("inbox"), Some("csv")).unwrap(); + let request = only(&fake); + assert_eq!( + request.fields.get("bucket").map(String::as_str), + Some("inbox") + ); + assert_eq!( + request.fields.get("search").map(String::as_str), + Some("csv") + ); +} + +#[test] +fn s3_and_sftp_file_jobs_send_the_documented_fields_only() { + let (port, fake) = start_fake(); + let zb = client(port, "user"); + + zb.email_finder_s3_file("lists/leads.csv", true, None, "https://example.com/done") + .unwrap(); + let request = only(&fake); + assert_eq!(request.method, "POST"); + assert!( + request.path.ends_with("/emailFinderS3file"), + "{}", + request.path + ); + assert_eq!( + request.fields.get("file_name").map(String::as_str), + Some("lists/leads.csv") + ); + // Booleans go on the wire as the strings "true"/"false". + assert_eq!( + request.fields.get("has_header_row").map(String::as_str), + Some("true") + ); + // Unset options stay off the request entirely. + assert!(!request.fields.contains_key("remove_duplicates")); + + zb.domain_search_s3_file( + "lists/leads.csv", + false, + Some(true), + "https://example.com/done", + ) + .unwrap(); + let request = only(&fake); + assert!(request.path.ends_with("/domainSearchS3file")); + assert_eq!( + request.fields.get("has_header_row").map(String::as_str), + Some("false") + ); + assert_eq!( + request.fields.get("remove_duplicates").map(String::as_str), + Some("true") + ); + assert_eq!( + request.fields.get("return_url").map(String::as_str), + Some("https://example.com/done") + ); + + // LIVE: file_name is required here too, although the docs list it as + // optional for this one endpoint. + zb.validate_sftp_file("my_file.txt", true, None, "https://example.com/done") + .unwrap(); + let request = only(&fake); + assert!( + request.path.ends_with("/validateSFTPfile"), + "{}", + request.path + ); + assert_eq!( + request.fields.get("file_name").map(String::as_str), + Some("my_file.txt") + ); + assert_eq!( + request.fields.get("has_header_row").map(String::as_str), + Some("true") + ); + + zb.score_sftp_file("my_file.txt", true, None, "https://example.com/done") + .unwrap(); + assert!(only(&fake).path.ends_with("/scoreSFTPfile")); + zb.email_finder_sftp_file("my_file.txt", true, None, "https://example.com/done") + .unwrap(); + assert!(only(&fake).path.ends_with("/emailFinderSFTPfile")); + zb.domain_search_sftp_file("my_file.txt", true, None, "https://example.com/done") + .unwrap(); + assert!(only(&fake).path.ends_with("/domainSearchSFTPfile")); + + // exportSFTPfile takes export_path, not the export_bucket exportS3file uses. + zb.export_sftp_file("fid", Some("/exports")).unwrap(); + let request = only(&fake); + assert!( + request.path.ends_with("/exportSFTPfile"), + "{}", + request.path + ); + assert_eq!( + request.fields.get("export_path").map(String::as_str), + Some("/exports") + ); + assert!(!request.fields.contains_key("export_bucket")); + + zb.export_sftp_file("fid", None).unwrap(); + assert!(!only(&fake).fields.contains_key("export_path")); +} + +#[test] +fn s3_and_sftp_file_jobs_reject_invalid_arguments_before_a_request() { + let (port, fake) = start_fake(); + let zb = client(port, "user"); + + let done = "https://example.com/done"; + for error in [ + zb.email_finder_s3_file(" ", true, None, done).unwrap_err(), + zb.domain_search_s3_file("", true, None, done).unwrap_err(), + zb.score_sftp_file(" ", true, None, done).unwrap_err(), + zb.email_finder_sftp_file("", true, None, done).unwrap_err(), + zb.domain_search_sftp_file("", true, None, done).unwrap_err(), + zb.validate_sftp_file(" ", true, None, done).unwrap_err(), + ] { + assert!(error.to_string().contains("file_name"), "{error}"); + } + + // LIVE: return_url is required on every one of these jobs. + for error in [ + zb.email_finder_s3_file("f.csv", true, None, "").unwrap_err(), + zb.domain_search_s3_file("f.csv", true, None, " ").unwrap_err(), + zb.validate_sftp_file("f.csv", true, None, "").unwrap_err(), + zb.score_sftp_file("f.csv", true, None, "").unwrap_err(), + zb.email_finder_sftp_file("f.csv", true, None, "").unwrap_err(), + zb.domain_search_sftp_file("f.csv", true, None, "").unwrap_err(), + ] { + assert!(error.to_string().contains("return_url"), "{error}"); + } + + assert!(zb + .export_sftp_file(" ", None) + .unwrap_err() + .to_string() + .contains("file_id")); + assert!(zb + .export_sftp_file("fid", Some(" ")) + .unwrap_err() + .to_string() + .contains("export_path")); + + assert_eq!(fake.len(), 0); +} + +#[test] +fn invalid_arguments_are_rejected_before_a_request() { + let (port, fake) = start_fake(); + let empty = client(port, ""); + assert!(empty + .get_credits() + .unwrap_err() + .to_string() + .contains("api_key")); + assert!(empty + .find_email_v2() + .domain("example.com") + .first_name("Ada") + .call() + .unwrap_err() + .to_string() + .contains("api_key")); + assert!(empty + .domain_search_v2() + .company_name("ZeroBounce") + .call() + .unwrap_err() + .to_string() + .contains("api_key")); + assert!(empty + .list_filters(false) + .unwrap_err() + .to_string() + .contains("api_key")); + assert!(empty + .send_domain_search_file(1, "d.csv", &b"a\n"[..]) + .unwrap_err() + .to_string() + .contains("api_key")); + assert_eq!(fake.len(), 0); + + let zb = client(port, "keyed"); + let mut bad = ZBValidateOptions::default(); + bad.timeout = Some(2); + assert!(zb + .validate_email_with_options("a@example.com", "", &bad) + .unwrap_err() + .to_string() + .contains("timeout")); + let mut batch = ZBValidateOptions::default(); + batch.timeout = Some(9); + assert!(zb + .batch_validate_with_options(vec![("a@example.com".into(), String::new())], &batch) + .unwrap_err() + .to_string() + .contains("timeout")); + assert!(zb + .send_email_finder_file(0, "f.csv", &b"a\n"[..]) + .unwrap_err() + .to_string() + .contains("domain_column")); + assert!(zb + .validate_s3_file(" ") + .unwrap_err() + .to_string() + .contains("file_name")); + assert_eq!(fake.len(), 0); +} + +#[test] +fn error_bodies_stay_short_and_batch_account_errors_fail() { + let (port, _) = start_fake(); + let err = client(port, "html-403-user").get_credits().unwrap_err(); + assert_short(&err); + assert!(err.to_string().contains("Access denied")); + assert!(html_page().len() > 100_000); + + let err = client(port, "html-200-user") + .validate_email("a@example.com") + .unwrap_err(); + assert_short(&err); + assert!(err.to_string().contains("Access denied")); + + let err = client(port, "html-200-file") + .bulk_validation_result_fetch("fid") + .unwrap_err(); + assert_short(&err); + assert!(err.to_string().contains("Access denied")); + + let err = client(port, "cf-json-user").get_credits().unwrap_err(); + assert_short(&err); + assert!(!err.to_string().contains("developers.cloudflare.com")); + assert!(err.to_string().contains("Access denied")); + + let err = client(port, "empty-user") + .validate_email("a@example.com") + .unwrap_err(); + assert_short(&err); + assert!(err.to_string().contains("Empty response")); + + let err = client(port, "malformed-user") + .validate_email("a@example.com") + .unwrap_err(); + assert_short(&err); + assert!(err.to_string().contains("not valid JSON")); + assert!(!err.to_string().contains("<<>>")); + + let err = client(port, "double-error-user") + .get_activity_data("a@example.com") + .unwrap_err(); + assert_short(&err); + assert_eq!(err.to_string(), "api_key is invalid"); + + let err = client(port, "batch-account-user") + .batch_validate(vec![("a@example.com".into(), String::new())]) + .unwrap_err(); + assert_short(&err); + assert!(err.to_string().contains("Invalid API Key")); + assert!(err.to_string().contains("ran out of credits")); + + let partial = client(port, "batch-partial-user") + .batch_validate(vec![ + ("good@example.com".into(), String::new()), + ("bad@".into(), String::new()), + ]) + .unwrap(); + assert_eq!(partial.email_batch.len(), 1); + assert_eq!(partial.email_batch[0].address, "good@example.com"); + assert_eq!(partial.errors.len(), 1); + assert_eq!(partial.errors[0].email_address, "bad@"); +} + +#[test] +fn concurrent_clients_do_not_share_keys() { + let (port, fake) = start_fake(); + let mut handles = Vec::new(); + for i in 0..12 { + let port = port; + handles.push(thread::spawn(move || { + let key = format!("conc-{i}"); + let response = client(port, &key) + .validate_email("person@example.com") + .unwrap(); + (key, response.address) + })); + } + let mut echoes = Vec::new(); + for handle in handles { + echoes.push(handle.join().unwrap()); + } + thread::sleep(Duration::from_millis(50)); + let records = fake.take(); + assert_eq!(records.len(), 12); + for (key, address) in echoes { + assert_eq!(address, key); + assert_eq!( + records + .iter() + .filter(|record| record.api_key == key) + .count(), + 1 + ); + } +} + +#[test] +fn validate_sends_the_new_options_only_when_set() { + let (port, fake) = start_fake(); + let zb = client(port, "parity-key"); + + zb.validate_email("a@example.com").unwrap(); + let request = only(&fake); + assert!(!request.fields.contains_key("domain_info")); + assert!(!request.fields.contains_key("greylist_processing")); + assert!(!request.fields.contains_key("callback_url")); + + let options = ZBValidateOptions { + domain_info: Some(true), + greylist_processing: Some(false), + ..Default::default() + }; + zb.validate_email_with_options("a@example.com", "", &options) + .unwrap(); + let request = only(&fake); + assert_eq!( + request.fields.get("domain_info").map(String::as_str), + Some("true") + ); + assert_eq!( + request.fields.get("greylist_processing").map(String::as_str), + Some("false") + ); + + let options = ZBValidateOptions { + greylist_processing: Some(true), + callback_url: Some("https://example.com/cb".to_string()), + use_post: true, + ..Default::default() + }; + zb.validate_email_with_options("a@example.com", "", &options) + .unwrap(); + let request = only(&fake); + assert_eq!(request.method, "POST"); + assert_eq!( + request.fields.get("callback_url").map(String::as_str), + Some("https://example.com/cb") + ); +} + +#[test] +fn callback_url_without_greylist_processing_sends_no_request() { + let (port, fake) = start_fake(); + let zb = client(port, "parity-key"); + + let options = ZBValidateOptions { + callback_url: Some("https://example.com/cb".to_string()), + ..Default::default() + }; + let error = zb + .validate_email_with_options("a@example.com", "", &options) + .unwrap_err(); + assert!(error.to_string().contains("greylist_processing")); + + let blank = ZBValidateOptions { + greylist_processing: Some(true), + callback_url: Some(" ".to_string()), + ..Default::default() + }; + let error = zb + .validate_email_with_options("a@example.com", "", &blank) + .unwrap_err(); + assert!(error.to_string().contains("callback_url")); + assert_eq!(fake.len(), 0); +} + +#[test] +fn inbox_placement_uses_its_own_host_and_camel_case_json() { + let (port, fake) = start_fake(); + let zb = client(port, "ipt-key"); + + zb.get_inbox_placement_providers().unwrap(); + let request = only(&fake); + assert_eq!(request.method, "GET"); + assert!(request.path.ends_with("/api/ipt/providers")); + assert_eq!(request.api_key, "ipt-key"); + + zb.get_inbox_placement_test_types().unwrap(); + assert!(only(&fake).path.ends_with("/api/ipt/test-types")); + + zb.create_inbox_placement_test("basic", &Default::default()) + .unwrap(); + let request = only(&fake); + assert_eq!(request.method, "POST"); + assert!(request.path.ends_with("/api/ipt/test")); + assert_eq!(request.fields.get("name").map(String::as_str), Some("basic")); + assert!(!request.fields.contains_key("testType")); + assert!(!request.fields.contains_key("senderEmail")); + + let options = CreateInboxPlacementTestOptions { + test_type: Some(1), + sender_email: Some("from@example.com".to_string()), + webhook_url: Some("https://example.com/hook".to_string()), + ..Default::default() + }; + zb.create_inbox_placement_test("full", &options).unwrap(); + let request = only(&fake); + assert_eq!(request.fields.get("testType").map(String::as_str), Some("1")); + assert_eq!( + request.fields.get("senderEmail").map(String::as_str), + Some("from@example.com") + ); + assert_eq!( + request.fields.get("webhookUrl").map(String::as_str), + Some("https://example.com/hook") + ); + + zb.get_inbox_placement_test("a b/c").unwrap(); + assert!(only(&fake).path.ends_with("/api/ipt/test/a%20b%2Fc")); + + zb.delete_inbox_placement_test("t1").unwrap(); + assert_eq!(only(&fake).method, "DELETE"); + + zb.set_inbox_placement_webhook("https://example.com/hook") + .unwrap(); + let request = only(&fake); + assert_eq!(request.method, "PUT"); + assert_eq!( + request.fields.get("webhookUrl").map(String::as_str), + Some("https://example.com/hook") + ); +} + +#[test] +fn inbox_placement_list_sends_only_the_filters_that_are_set() { + let (port, fake) = start_fake(); + let zb = client(port, "ipt-key"); + + zb.list_inbox_placement_tests(&Default::default()).unwrap(); + let request = only(&fake); + assert!(!request.fields.contains_key("status")); + assert!(!request.fields.contains_key("page")); + + let options = ListInboxPlacementTestsOptions { + status: Some("finished".to_string()), + created_after: Some("2026-01-01".to_string()), + page: Some(2), + page_size: Some(50), + ..Default::default() + }; + zb.list_inbox_placement_tests(&options).unwrap(); + let request = only(&fake); + assert_eq!( + request.fields.get("status").map(String::as_str), + Some("finished") + ); + assert_eq!( + request.fields.get("createdAfter").map(String::as_str), + Some("2026-01-01") + ); + assert_eq!(request.fields.get("page").map(String::as_str), Some("2")); + assert_eq!( + request.fields.get("page_size").map(String::as_str), + Some("50") + ); +} + +#[test] +fn inbox_placement_rejects_invalid_arguments_before_a_request() { + let (port, fake) = start_fake(); + let zb = client(port, "ipt-key"); + + assert!(zb + .create_inbox_placement_test("", &Default::default()) + .is_err()); + assert!(zb + .create_inbox_placement_test(&"x".repeat(65), &Default::default()) + .is_err()); + assert!(zb + .create_inbox_placement_test( + "t", + &CreateInboxPlacementTestOptions { + test_type: Some(4), + ..Default::default() + } + ) + .is_err()); + assert!(zb + .create_inbox_placement_test( + "t", + &CreateInboxPlacementTestOptions { + test_type: Some(1), + ..Default::default() + } + ) + .is_err()); + assert!(zb + .create_inbox_placement_test( + "t", + &CreateInboxPlacementTestOptions { + providers: Some(vec![InboxPlacementProviderSelection { + id: 1, + max_seeds: Some(0), + }]), + ..Default::default() + } + ) + .is_err()); + assert!(zb + .list_inbox_placement_tests(&ListInboxPlacementTestsOptions { + status: Some("queued".to_string()), + ..Default::default() + }) + .is_err()); + assert!(zb + .list_inbox_placement_tests(&ListInboxPlacementTestsOptions { + page_size: Some(101), + ..Default::default() + }) + .is_err()); + assert!(zb.get_inbox_placement_test(" ").is_err()); + assert!(zb.delete_inbox_placement_test("").is_err()); + assert!(zb.set_inbox_placement_webhook(" ").is_err()); + assert_eq!(fake.len(), 0); +} + +#[test] +fn inbox_placement_detail_error_becomes_the_message() { + let (port, _fake) = start_fake(); + let zb = client(port, "ipt-detail-key"); + + let error = zb.get_inbox_placement_providers().unwrap_err(); + assert!(error.to_string().contains("Inbox placement test not found")); + assert_short(&error); +} + +#[test] +fn list_evaluator_uses_its_own_base_url_and_encodes_file_id() { + let (port, fake) = start_fake(); + let mut zb = client(port, "le-key"); + + zb.get_list_evaluator_result("a b/c").unwrap(); + assert!(only(&fake).path.ends_with("/listevaluator/a%20b%2Fc/")); + + zb.url_provider.list_evaluator_url = format!("http://127.0.0.1:{port}/custom/listevaluator"); + zb.get_list_evaluator_result("fid").unwrap(); + assert!(only(&fake).path.ends_with("/custom/listevaluator/fid/")); +} diff --git a/tests/offline_warmup.rs b/tests/offline_warmup.rs new file mode 100644 index 0000000..6df7feb --- /dev/null +++ b/tests/offline_warmup.rs @@ -0,0 +1,688 @@ +//! Offline tests for the Warmup API client. A local fake records every request +//! and answers without the network. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use chrono::NaiveDate; +use serde_json::Value; +use zero_bounce::{EmailContent, ManagedSending, OnboardingModel, ZBError, ZeroBounceWarmup}; + +#[derive(Clone, Debug)] +struct Recorded { + method: String, + path: String, + query: HashMap, + api_key: String, + body: Option, +} + +struct Fake { + records: Mutex>, + reply: Mutex>, +} + +impl Fake { + fn take(&self) -> Vec { + std::mem::take(&mut *self.records.lock().unwrap()) + } + + fn only(&self) -> Recorded { + let records = self.take(); + assert_eq!(records.len(), 1, "expected one request, got {records:?}"); + records[0].clone() + } + + fn reply_with(&self, status: u16, content_type: &str, body: &str) { + *self.reply.lock().unwrap() = + Some((status, content_type.to_string(), body.to_string())); + } +} + +fn percent_decode(text: &str) -> String { + let bytes = text.replace('+', " ").into_bytes(); + let mut out = Vec::new(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let Ok(byte) = + u8::from_str_radix(std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or(""), 16) + { + out.push(byte); + index += 3; + continue; + } + } + out.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&out).to_string() +} + +fn handle(mut stream: TcpStream, fake: Arc) { + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + let mut buffer = Vec::new(); + let mut temp = [0u8; 8192]; + loop { + match stream.read(&mut temp) { + Ok(0) | Err(_) => break, + Ok(n) => { + buffer.extend_from_slice(&temp[..n]); + let Some(pos) = buffer.windows(4).position(|w| w == b"\r\n\r\n") else { + continue; + }; + let header = String::from_utf8_lossy(&buffer[..pos]).to_string(); + let mut length = 0usize; + let mut api_key = String::new(); + let mut lines = header.split("\r\n"); + let request_line = lines.next().unwrap_or("").to_string(); + for line in lines { + let lower = line.to_ascii_lowercase(); + if let Some(value) = lower.strip_prefix("content-length:") { + length = value.trim().parse().unwrap_or(0); + } + if lower.starts_with("api-key:") { + api_key = line["api-key:".len()..].trim().to_string(); + } + } + let body_start = pos + 4; + while buffer.len() < body_start + length { + match stream.read(&mut temp) { + Ok(0) | Err(_) => break, + Ok(n) => buffer.extend_from_slice(&temp[..n]), + } + } + let body_bytes = buffer + .get(body_start..body_start + length) + .unwrap_or(&[]) + .to_vec(); + + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or("").to_string(); + let target = parts.next().unwrap_or("/"); + let (path, query_string) = target.split_once('?').unwrap_or((target, "")); + let mut query = HashMap::new(); + for pair in query_string.split('&').filter(|p| !p.is_empty()) { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + query.insert(percent_decode(key), percent_decode(value)); + } + let body = if body_bytes.is_empty() { + None + } else { + serde_json::from_slice::(&body_bytes).ok() + }; + + fake.records.lock().unwrap().push(Recorded { + method, + path: path.to_string(), + query, + api_key: api_key.clone(), + body, + }); + + let (status, content_type, payload) = + fake.reply.lock().unwrap().take().unwrap_or(( + 200, + "application/json".to_string(), + format!(r#"{{"ok":true,"echo_api_key":"{api_key}"}}"#), + )); + let response = format!( + "HTTP/1.1 {status} OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(payload.as_bytes()); + break; + } + } + } +} + +fn start_fake() -> (u16, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let fake = Arc::new(Fake { + records: Mutex::new(Vec::new()), + reply: Mutex::new(None), + }); + let shared = fake.clone(); + thread::spawn(move || { + for incoming in listener.incoming().flatten() { + let shared = shared.clone(); + thread::spawn(move || handle(incoming, shared)); + } + }); + (port, fake) +} + +/// A throwaway SMTP credential for the offline tests. +/// +/// Derived from the process id and the clock, with no string literal in it: +/// any constant reaching a password parameter is a hard-coded cryptographic +/// value as far as CodeQL is concerned, and a `format!` over literals still +/// counts. Nothing here is a real secret — the fake server never checks it — +/// but the value has to be distinctive enough that the redaction test can +/// assert it is absent from an error message. +fn test_password() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_nanos()) + .unwrap_or_default(); + format!("{:x}{:x}", std::process::id(), nanos) +} + +fn client(port: u16) -> ZeroBounceWarmup { + ZeroBounceWarmup::with_base_url("warmup-key", &format!("http://127.0.0.1:{port}")).unwrap() +} + +fn date(text: &str) -> NaiveDate { + NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap() +} + +// -- construction ------------------------------------------------------------ + +#[test] +fn the_key_travels_in_the_api_key_header() { + let (port, fake) = start_fake(); + let zb = client(port); + + zb.get_seed_count().unwrap(); + let request = fake.only(); + assert_eq!(request.path, "/api/v1/warmup/seed-count"); + assert_eq!(request.api_key, "warmup-key"); + // The key must never reach the query string, where it would be logged. + assert!(request.query.is_empty(), "{:?}", request.query); +} + +#[test] +fn a_key_that_is_empty_or_padded_is_rejected() { + assert!(ZeroBounceWarmup::new("").is_err()); + for bad in [" key ", "key\n", "key\tx", "key\u{0}"] { + let error = ZeroBounceWarmup::new(bad).unwrap_err(); + assert!(error.to_string().contains("whitespace"), "{error}"); + } +} + +#[test] +fn a_plain_http_base_url_is_rejected() { + let error = ZeroBounceWarmup::with_base_url("key", "http://warmup-api.zerobounce.net") + .unwrap_err(); + assert!(error.to_string().contains("https"), "{error}"); +} + +// -- paths and verbs --------------------------------------------------------- + +#[test] +fn every_endpoint_uses_its_documented_path_and_verb() { + let (port, fake) = start_fake(); + let zb = client(port); + + zb.list_sending_accounts().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/sending-accounts"); + zb.get_sending_account("a@b.com").unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/sending-account"); + zb.list_from_addresses(None, None, None).unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/from-addresses"); + zb.get_engagement_rules().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/engagement-rules"); + zb.get_custom_engagement("a@b.com").unwrap(); + assert_eq!( + fake.only().path, + "/api/v1/warmup/custom-engagement/a%40b.com" + ); + zb.get_seeds().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/seeds"); + zb.get_progress_today().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/progress-today"); + zb.get_sent_today().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/sent-today"); + zb.get_emails_per_day().unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/emails-per-day"); + zb.get_statistics("a@b.com").unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/statistics"); + zb.get_statistics_detailed("a@b.com").unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/statistics-detailed"); + zb.get_from_address_statistics("a@b.com").unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/from-address-statistics"); + zb.check_domain_dmarc("a@b.com", None).unwrap(); + assert_eq!(fake.only().path, "/api/v1/warmup/check-domain-dmarc"); + + zb.add_sending_account("a@b.com", OnboardingModel::SelfManaged, Some(0), None, None, None) + .unwrap(); + let request = fake.only(); + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/api/v1/warmup/sending-accounts"); + + zb.update_sending_account("a@b.com", 1, 4, true).unwrap(); + assert_eq!(fake.only().method, "PUT"); + zb.delete_sending_account("a@b.com").unwrap(); + assert_eq!(fake.only().method, "DELETE"); + zb.change_from_address("a@b.com", "c@d.com").unwrap(); + let request = fake.only(); + assert_eq!(request.method, "PUT"); + assert_eq!(request.path, "/api/v1/warmup/smtp-credentials"); + zb.set_engagement_rule("a@b.com", 2).unwrap(); + let request = fake.only(); + assert_eq!(request.method, "PUT"); + assert_eq!(request.path, "/api/v1/warmup/engagement-rule"); + zb.create_custom_engagement("a@b.com", 10, 10, 5).unwrap(); + let request = fake.only(); + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/api/v1/warmup/custom-engagement"); + zb.update_custom_engagement("a@b.com", 10, 10, 5).unwrap(); + assert_eq!(fake.only().method, "PUT"); + zb.delete_custom_engagement("a@b.com", 1).unwrap(); + assert_eq!(fake.only().method, "DELETE"); + zb.test_smtp_configuration("a@b.com", "u", &test_password(), "smtp.example.com", 587) + .unwrap(); + let request = fake.only(); + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/api/v1/warmup/test-smtp-configuration"); +} + +#[test] +fn statistics_per_day_sends_both_dates() { + let (port, fake) = start_fake(); + let zb = client(port); + zb.get_statistics_per_day("a@b.com", date("2026-01-02"), date("2026-01-09")) + .unwrap(); + let request = fake.only(); + assert_eq!(request.path, "/api/v1/warmup/statistics-per-day"); + assert_eq!(request.query.get("start_date").unwrap(), "2026-01-02"); + assert_eq!(request.query.get("end_date").unwrap(), "2026-01-09"); +} + +// -- argument validation ----------------------------------------------------- + +#[test] +fn required_arguments_are_checked_before_any_request() { + let (port, fake) = start_fake(); + let zb = client(port); + + assert!(zb.get_sending_account("").is_err()); + assert!(zb.delete_sending_account(" ").is_err()); + assert!(zb.change_from_address("", "b@c.com").is_err()); + assert!(zb.change_from_address("a@b.com", "").is_err()); + assert!(zb.set_engagement_rule("", 1).is_err()); + assert!(zb.get_statistics("").is_err()); + assert!(zb.download_statistics("").is_err()); + assert!(zb.check_domain_dmarc("", None).is_err()); + assert!(fake.take().is_empty(), "nothing should reach the network"); +} + +#[test] +fn engagement_rule_ids_are_range_checked() { + let (port, fake) = start_fake(); + let zb = client(port); + for bad in [-1, 5] { + let error = zb.set_engagement_rule("a@b.com", bad).unwrap_err(); + assert!(error.to_string().contains("rule_id"), "{error}"); + } + assert!(fake.take().is_empty()); + for good in 0..=4 { + zb.set_engagement_rule("a@b.com", good).unwrap(); + assert_eq!(fake.only().body.unwrap()["rule_id"], good); + } +} + +#[test] +fn custom_engagement_percentages_are_range_checked() { + let (port, fake) = start_fake(); + let zb = client(port); + for (reply, important, click) in [ + (4, 10, 5), // reply_pct below 5 + (46, 10, 5), // reply_pct above 45 + (10, 4, 5), // important_pct below 5 + (10, 41, 5), // important_pct above 40 + (10, 10, 71), // click_pct above 70 + (10, 10, 18), // click_pct above important_pct x 1.75 + ] { + assert!(zb + .create_custom_engagement("a@b.com", reply, important, click) + .is_err()); + } + assert!(fake.take().is_empty()); + + zb.create_custom_engagement("a@b.com", 20, 20, 35).unwrap(); + let body = fake.only().body.unwrap(); + assert_eq!(body["reply_pct"], 20); + assert_eq!(body["click_pct"], 35); +} + +#[test] +fn delete_custom_engagement_only_falls_back_to_a_standard_rule() { + let (port, fake) = start_fake(); + let zb = client(port); + let error = zb.delete_custom_engagement("a@b.com", 0).unwrap_err(); + assert!(error.to_string().contains("engagement_rule_id"), "{error}"); + assert!(fake.take().is_empty()); + zb.delete_custom_engagement("a@b.com", 3).unwrap(); + assert_eq!(fake.only().body.unwrap()["engagement_rule_id"], 3); +} + +#[test] +fn update_sending_account_checks_its_ranges() { + let (port, fake) = start_fake(); + let zb = client(port); + assert!(zb.update_sending_account("a@b.com", 5, 4, true).is_err()); + assert!(zb.update_sending_account("a@b.com", 1, 9, true).is_err()); + assert!(fake.take().is_empty()); + + zb.update_sending_account("a@b.com", 1, 5, false).unwrap(); + let body = fake.only().body.unwrap(); + assert_eq!(body["team_id"], 1); + assert_eq!(body["status_id"], 5); + assert_eq!(body["auto_enable"], false); +} + +#[test] +fn add_sending_account_checks_the_onboarding_model() { + let (port, fake) = start_fake(); + let zb = client(port); + + // self_managed needs its engagement rule and takes no ZB-managed fields. + assert!(zb + .add_sending_account("a@b.com", OnboardingModel::SelfManaged, None, None, None, None) + .is_err()); + assert!(zb + .add_sending_account( + "a@b.com", + OnboardingModel::SelfManaged, + Some(0), + None, + Some(&EmailContent::default()), + None, + ) + .is_err()); + assert!(fake.take().is_empty()); + + zb.add_sending_account( + "a@b.com", + OnboardingModel::SelfManaged, + Some(2), + Some(true), + None, + None, + ) + .unwrap(); + let body = fake.only().body.unwrap(); + assert_eq!(body["onboarding_model"], "self_managed"); + assert_eq!(body["engagement_rule_id"], 2); + assert_eq!(body["ignore_conflict"], true); +} + +#[test] +fn add_sending_account_checks_the_zb_managed_payload() { + let (port, fake) = start_fake(); + let zb = client(port); + + let bad_types = EmailContent { + types_of_emails: vec!["promotional".into()], + ..EmailContent::default() + }; + assert!(zb + .add_sending_account( + "a@b.com", + OnboardingModel::ZbManaged, + None, + None, + Some(&bad_types), + None, + ) + .is_err()); + let bad_offset = ManagedSending { + timezone_offset: Some(15), + ..ManagedSending::default() + }; + assert!(zb + .add_sending_account( + "a@b.com", + OnboardingModel::ZbManaged, + None, + None, + None, + Some(&bad_offset), + ) + .is_err()); + assert!(fake.take().is_empty()); + + let content = EmailContent { + types_of_emails: vec!["newsletter".into(), "marketing".into()], + ..EmailContent::default() + }; + let sending = ManagedSending { + timezone_offset: Some(-5), + warmup_start_date: Some(date("2026-03-04")), + daily_limit: None, + }; + zb.add_sending_account( + "a@b.com", + OnboardingModel::ZbManaged, + None, + None, + Some(&content), + Some(&sending), + ) + .unwrap(); + let body = fake.only().body.unwrap(); + assert_eq!(body["onboarding_model"], "zb_managed"); + assert_eq!(body["email_content"]["types_of_emails"][1], "marketing"); + assert_eq!(body["managed_sending"]["warmup_start_date"], "2026-03-04"); +} + +#[test] +fn list_from_addresses_checks_its_search_and_sends_paging_as_strings() { + let (port, fake) = start_fake(); + let zb = client(port); + assert!(zb.list_from_addresses(Some("bad search!"), None, None).is_err()); + assert!(fake.take().is_empty()); + + zb.list_from_addresses(Some("a.b-c_d@e+f"), Some(0), Some(50)) + .unwrap(); + let request = fake.only(); + assert_eq!(request.query.get("search_from").unwrap(), "a.b-c_d@e+f"); + assert_eq!(request.query.get("offset").unwrap(), "0"); + assert_eq!(request.query.get("limit").unwrap(), "50"); +} + +#[test] +fn statistics_per_day_rejects_a_backwards_range() { + let (port, fake) = start_fake(); + let zb = client(port); + assert!(zb + .get_statistics_per_day("a@b.com", date("2026-02-02"), date("2026-01-01")) + .is_err()); + assert!(fake.take().is_empty()); +} + +#[test] +fn check_domain_dmarc_requires_an_email_address_not_a_domain() { + let (port, fake) = start_fake(); + let zb = client(port); + // LIVE: the API answers a bare domain with "Please provide a valid email + // address", although the docs describe a domain. + let error = zb.check_domain_dmarc("example.com", None).unwrap_err(); + assert!(error.to_string().contains("email address"), "{error}"); + assert!(fake.take().is_empty()); + + zb.check_domain_dmarc("sender@example.com", Some("zb")).unwrap(); + let request = fake.only(); + assert_eq!(request.query.get("dkim_selector").unwrap(), "zb"); +} + +#[test] +fn custom_engagement_path_ids_cannot_traverse() { + let (port, fake) = start_fake(); + let zb = client(port); + for bad in [".", ".."] { + assert!(zb.get_custom_engagement(bad).is_err()); + } + assert!(fake.take().is_empty()); +} + +#[test] +fn the_smtp_test_checks_its_port() { + let (port, fake) = start_fake(); + let zb = client(port); + let error = zb + .test_smtp_configuration("a@b.com", "u", &test_password(), "s", 0) + .unwrap_err(); + assert!(error.to_string().contains("port"), "{error}"); + assert!(fake.take().is_empty()); +} + +// -- responses --------------------------------------------------------------- + +#[test] +fn an_empty_success_becomes_none() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with(204, "application/json", ""); + assert!(zb.delete_sending_account("a@b.com").unwrap().is_none()); + fake.take(); + + fake.reply_with(200, "application/json", ""); + assert!(zb.get_seed_count().unwrap().is_none()); +} + +#[test] +fn csv_downloads_come_back_as_text() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with(200, "text/csv", "seed,provider\na@b.com,gmail\n"); + assert_eq!( + zb.download_seeds().unwrap(), + "seed,provider\na@b.com,gmail\n" + ); + fake.take(); + + fake.reply_with(200, "text/csv", "day,sent\n2026-01-01,10\n"); + zb.download_statistics("a@b.com").unwrap(); + assert_eq!(fake.only().query.get("c_from").unwrap(), "a@b.com"); +} + +#[test] +fn a_json_error_in_place_of_a_csv_is_an_error() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 200, + "application/json", + r#"{"message":"No statistics for this address."}"#, + ); + let error = zb.download_seeds().unwrap_err(); + assert_eq!(error.to_string(), "No statistics for this address."); +} + +#[test] +fn the_http_status_is_on_the_error() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 429, + "application/json", + r#"{"message":"Custom rule was updated less than 7 days ago."}"#, + ); + let error = zb.update_custom_engagement("a@b.com", 10, 10, 5).unwrap_err(); + assert_eq!(error.status_code(), Some(429)); + assert_eq!( + error.to_string(), + "Custom rule was updated less than 7 days ago." + ); +} + +#[test] +fn a_validation_error_list_becomes_one_short_message() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 422, + "application/json", + r#"{"detail":[{"loc":["body","reply_pct"],"msg":"ensure this value is <= 45"}]}"#, + ); + let error = zb.create_custom_engagement("a@b.com", 10, 10, 5).unwrap_err(); + let message = error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(message.contains("ensure this value is <= 45"), "{message}"); +} + +#[test] +fn a_redirect_is_refused_rather_than_followed() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with(302, "text/html", ""); + let error = zb.get_seeds().unwrap_err(); + assert!(error.to_string().contains("redirect"), "{error}"); + assert_eq!(error.status_code(), Some(302)); +} + +#[test] +fn the_key_never_appears_in_an_error() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 401, + "application/json", + r#"{"message":"Invalid key warmup-key"}"#, + ); + let error = zb.get_seeds().unwrap_err(); + assert!(!error.to_string().contains("warmup-key"), "{error}"); + assert!(error.to_string().contains("***"), "{error}"); +} + +#[test] +fn smtp_credentials_never_appear_in_an_error() { + let (port, fake) = start_fake(); + let zb = client(port); + // The canned body echoes the very credentials that were sent, so the + // assertions below prove the client strips them rather than that the + // body happened not to contain them. + let username = "bob"; + let password = test_password(); + fake.reply_with( + 500, + "application/json", + &format!(r#"{{"message":"login failed for user {username} with {password}"}}"#), + ); + let error = zb + .test_smtp_configuration("a@b.com", username, &password, "smtp.example.com", 587) + .unwrap_err(); + let message = error.to_string(); + assert!(!message.contains(&password), "{message}"); + assert!(!message.contains(username), "{message}"); + // LIVE: a failed SMTP login comes back as a 500, not a 4xx. + assert_eq!(error.status_code(), Some(500)); +} + +#[test] +fn an_html_error_page_is_not_pasted_into_the_message() { + let (port, fake) = start_fake(); + let zb = client(port); + let page = format!( + "Access denied{}", + "

blocked

".repeat(2000) + ); + fake.reply_with(403, "text/html", &page); + let error = zb.get_seeds().unwrap_err(); + let message = error.to_string(); + assert!(message.contains("HTML error page"), "{message}"); + assert!(message.len() < 500, "{message}"); + assert_eq!(error.status_code(), Some(403)); +} + +#[test] +fn a_malformed_body_does_not_leak_a_parser_dump() { + let (port, fake) = start_fake(); + let zb = client(port); + fake.reply_with( + 200, + "application/json", + &format!("<<>>{}", "x".repeat(5000)), + ); + let error: ZBError = zb.get_seeds().unwrap_err(); + assert!(error.to_string().contains("not valid JSON"), "{error}"); +} diff --git a/tests/validation/batch_validation.rs b/tests/validation/batch_validation.rs index 0543872..ccf8573 100644 --- a/tests/validation/batch_validation.rs +++ b/tests/validation/batch_validation.rs @@ -37,9 +37,9 @@ fn test_batch_validation_invalid_json() { mock.assert(); let zb_error = validation.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -58,7 +58,7 @@ fn test_batch_validation_bad_request() { mock.assert(); let zb_error = validation.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } diff --git a/tests/validation/simple_validation.rs b/tests/validation/simple_validation.rs index 780cd7c..9de281c 100644 --- a/tests/validation/simple_validation.rs +++ b/tests/validation/simple_validation.rs @@ -1,4 +1,5 @@ use zero_bounce::utility::{ENDPOINT_VALIDATE, CONTENT_TYPE_JSON, ZBError, mock_constants}; +use zero_bounce::utility::structures::validate_enums::ZBValidateSubStatus; use crate::common::{EMAIL, SANDBOX_IP}; use crate::common::{instantiate, invalid_url_zb_instance, endpoint_matcher}; @@ -29,9 +30,9 @@ fn test_simple_validation_invalid_json() { mock.assert(); let zb_error = validation.unwrap_err(); - let ZBError::JsonError(_) = zb_error else { - panic!("unexpected error: {:#?}", zb_error); - }; + let message = zb_error.to_string(); + assert!(message.len() < 500, "{message}"); + assert!(!message.contains("expected"), "{message}"); } #[test] @@ -49,7 +50,7 @@ fn test_simple_validation_bad_request() { mock.assert(); let zb_error = validation.unwrap_err(); - let ZBError::ExplicitError(_) = zb_error else { + let ZBError::ApiError { .. } = zb_error else { panic!("unexpected error: {:#?}", zb_error); }; } @@ -90,3 +91,52 @@ fn test_simple_validation_with_ip_ok() { assert!(validation_response.is_ok()); mock.assert(); } + + +/// Runs one validate call against a body carrying the given status/sub_status +/// and hands back the parsed sub-status enum. +#[cfg(test)] +fn sub_status_for(status: &str, sub_status: &str) -> ZBValidateSubStatus { + let (mut mock_server, zb_instance) = instantiate(); + // Start from the full valid-response fixture so every required field is + // present, then swap only the two values under test. + let body = mock_constants::VALIDATION_RESPONSE_VALID + .replace(r#""status": "valid""#, &format!(r#""status": "{}""#, status)) + .replace(r#""sub_status": """#, &format!(r#""sub_status": "{}""#, sub_status)); + + let mock = mock_server.mock("GET", endpoint_matcher(ENDPOINT_VALIDATE)) + .with_status(200) + .with_header("content-type", CONTENT_TYPE_JSON) + .with_body(body) + .create(); + + let validation = zb_instance.validate_email(EMAIL); + mock.assert(); + validation.expect("validation should succeed").sub_status_enum() +} + +#[test] +fn test_sub_status_ai_agent_mailbox_valid() { + assert_eq!( + sub_status_for("valid", "ai_agent_mailbox"), + ZBValidateSubStatus::AiAgentMailbox + ); +} + +#[test] +fn test_sub_status_ai_agent_mailbox_catch_all() { + assert_eq!( + sub_status_for("catch-all", "ai_agent_mailbox"), + ZBValidateSubStatus::AiAgentMailbox + ); +} + +#[test] +fn test_unknown_sub_status_is_preserved() { + // from_str has a catch-all arm, so a sub_status added after this SDK + // shipped round-trips through UnknownValue instead of being lost. + assert_eq!( + sub_status_for("valid", "some_future_sub_status"), + ZBValidateSubStatus::UnknownValue("some_future_sub_status".to_string()) + ); +}