Skip to content

feat: v2 validate parity (verify_plus, activity_data, batch timeout) - #19

Merged
franciscbalint merged 17 commits into
mainfrom
feature/api-parity-p1-validate
Oct 24, 2026
Merged

franciscbalint merged 17 commits into
mainfrom
feature/api-parity-p1-validate

Conversation

@franciscbalint

@franciscbalint franciscbalint commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Docs: https://www.zerobounce.net/docs/email-validation-api-quickstart/v2-validate-emails

Made with Cursor


Validate options, Inbox Placement Tester, list evaluator base URL

Ported from the Python SDK (407f0a8, a6710e5).

Added

  • ZBValidateOptions gains domain_info, greylist_processing, callback_url. Booleans go on the wire as "true"/"false" on both the query and the form POST. callback_url is rejected without greylist_processing: Some(true), and when blank, before any request is sent.
  • ZBValidation gains domain_website_exists, domain_registrant_company_name, and message (renamed from the API's Message key).
  • Inbox Placement Tester in src/api/inbox_placement.rs 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. API key on the query string, camelCase JSON bodies, unset fields omitted.
  • ZBUrlProvider gains list_evaluator_url and inbox_placement_url, both overridable.

Changed

  • List evaluator uses list_evaluator_url instead of building the path from the bulk base; the file id is URL-encoded as one path segment. send_evaluated_file stays on the bulk base.
  • A greylist pickup still in progress returns no processed_at or free_email, so both now #[serde(default)] rather than failing the whole response.

Fixed

  • An error body with a detail string (Django REST framework, used by the Inbox Placement Tester) returns that text.

S3 — removed, then restored

  • An earlier push removed validate_s3_file, score_s3_file, export_s3_file, list_s3_files. That was wrong: they are documented, at https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api. All of them, the S3ListFileEntry struct, the post_bulk_root_form helper and the tests have been restored; only the parity work above remains.
  • AGENTS.md keeps the "only documented endpoints" rule and now points at where the S3 docs live.
  • Not added: emailFinderS3file and domainSearchS3file, and the SFTP variants on the Integrations index, are documented but have never been part of this SDK.

Notes

  • reqwest's json() helper needs a feature this crate does not enable, so the JSON body and content type are set directly rather than widening the dependency. The path-segment encoder is a few lines in utility for the same reason.
  • The offline test client now points list_evaluator_url and inbox_placement_url at the fake server. Without that the suite reached the live API — which is how the list evaluator test first failed.

New: the rest of the documented bulk surface

Seven endpoints that are documented but had never been in this SDK:

All six file jobs go through one bulk_root_file_job helper, which sends has_header_row and remove_duplicates as the strings true/false and omits remove_duplicates and return_url until they are set.

Two shapes differ from their S3 counterparts and are easy to get wrong:

  • validate_sftp_file's file_name is optional, so it takes Option<&str> — None uses the input bucket from the account's settings. Every other job requires a name, and a blank one is still rejected.
  • export_sftp_file takes export_path, not the export_bucket that export_s3_file uses. None uses the export path from those settings.

The SFTP server, path and protocol are never sent by the SDK; they come from the account's ZeroBounce settings. The existing S3 methods are untouched, so no caller signatures change.

Tests: 132 pass across the four suites; clippy reports no new warnings. Offline only — no live API calls.


RD-66: ai_agent_mailbox sub_status

Folded in from the former feature/ai-agent-mailbox-substatus branch, which has been deleted now that the commit lives here.

Adds the ai_agent_mailbox sub_status, which the API returns alongside both valid and catch-all. Tests cover both pairings, plus an unrecognised sub_status to pin that a value added to the API after this SDK ships degrades gracefully instead of failing the whole validate call.

No sub_status_ai_agent_mailbox usage counter was added: it is not documented.


Python-SDK parity, and the live API's actual behaviour

Brings this SDK level with the Python SDK's master. Where the live API and the docs disagree, the API wins and the call site says so.

What the live API taught us. Each of these was verified by calling the endpoint from the JavaScript, Go and PHP SDKs against the real API, and is fixed here too:

  • 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" — which looks like a billing problem, not a payload one.
  • The Inbox Placement Tester authenticates with Authorization: Bearer, not an api_key parameter. The key no longer reaches a query string that gets logged.
  • send_evaluated_file takes the list evaluator's file_id as a form field. A file upload is rejected with file_id: This field is required, so the signature is now send_evaluated_file(file_id: &str).
  • The S3 and SFTP jobs require file_name and return_url — validate_sftp_file included, although the docs list file_name as optional for that one. Both parameters are now &str rather than Option.

A bug the live runs exposed. {"success": false} on an HTTP 200 was returned as data: the API answers an unknown bulk file id that way, and two tests had pinned that wrong behaviour. It now raises, and ZBError::ApiError carries the HTTP status (ZBError::status_code()) so callers can tell a Cloudflare 403 from an API 400/404 and a Warmup 429.

New: the Warmup API client. ZeroBounceWarmup covers all 26 documented endpoints of what is a separate product — its own host, its own team-scoped key in an API-Key header, two CSV downloads. Ranges are checked before sending (engagement rules 0–4, reply 5–45 %, important 5–40 %, click ≤ 70 % and ≤ important × 1.75), 204 and empty 200 come back as Ok(None), check_domain_dmarc requires an email address (the live API rejects a bare domain), and the SMTP test masks the credentials out of every error it raises. A key with surrounding whitespace or control characters is refused before it can be quoted unredacted by the transport.

Testing. 162 offline tests pass (cargo test), no network.

The live suite ran green: 40 passed, 0 failed, 4 skipped. The four skips
are the per-address Warmup statistics; this team has no sending account. It
was run in a rust:1-bookworm container, which reaches both crates.io and the
API from this machine. An earlier revision of this description claimed the
suite could not be run here at all — that was wrong, and this replaces it.

A bug the live run found

get_api_usage always failed. The API returns only the counters that
apply to the account and the date range asked for, and an ordinary response
carries neither sub_status_gold nor sub_status_role_based_accept_all. Both
were required fields, so serde failed the whole response — surfaced as
"ZeroBounce returned a response that is not valid JSON", because the body is
over 1000 characters and carries no message or error key. Every counter on
ApiUsage now defaults to 0 when absent, so a counter the API adds or drops
later is ignored rather than fatal, and
sub_status_leading_period_removed / sub_status_alias_address, which the API
does return, are now read. The iOS SDK had the same bug from the same two
fields; it is fixed there too.

To run it yourself:

export ZEROBOUNCE_API_KEY=...
export ZEROBOUNCE_WARMUP_API_KEY=...
export ZEROBOUNCE_LIVE_SPEND=1 ZEROBOUNCE_LIVE_CHANGES=1
cargo test --features live --test live_api -- --nocapture

It is behind the live cargo feature, so cargo test and CI never compile it. Free calls run by default and assert the credit balance did not move; ZEROBOUNCE_LIVE_SPEND=1 adds the billable calls and a bulk round trip that deletes its own job, and ZEROBOUNCE_LIVE_CHANGES=1 adds a test filter and a self-managed Warmup sending account, both removed 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.

No version bump, tag or publish.

🤖 Generated with Claude Code

franciscbalint and others added 4 commits October 18, 2026 00:52
Add ZBValidateOptions and with_options helpers; map activity fields on ZBValidation.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Add form-encoded POST option on single validate (alongside GET) and GET
listS3files on bulkapi root, with unit tests and API parity doc updates.
Comment thread src/lib.rs Fixed
franciscbalint and others added 10 commits October 18, 2026 14:59
Co-authored-by: Cursor <cursoragent@cursor.com>
HTML pages, empty payloads, and account-level batch responses were succeeding or exposing parser text, and an empty key was still sent.

Co-authored-by: Cursor <cursoragent@cursor.com>
…st evaluator base URL

Ports the Python SDK's parity changes (407f0a8, a6710e5) to this SDK.

- ZBValidateOptions takes domain_info, greylist_processing and callback_url,
  sent as the strings "true"/"false" on both the query and the form POST.
  callback_url without greylist_processing: Some(true), and a blank one, are
  rejected before any request goes out.
- ZBValidation carries domain_website_exists, domain_registrant_company_name
  and message, the last renamed from the API's "Message" key. A greylist pickup
  that is still running carries no processed_at or free_email, so both now
  default rather than failing the whole response.
- Inbox Placement Tester in src/api/inbox_placement.rs on
  bulkapi.zerobounce.net/api/ipt: providers, test types, create/get/list/delete
  tests, and get/set webhook. The API key goes on the query string, bodies are
  camelCase JSON, and only fields the caller set are sent. reqwest's json()
  helper needs a feature this crate does not enable, so the body and content
  type are set directly rather than widening the dependency.
- ZBUrlProvider gains list_evaluator_url and inbox_placement_url. List
  evaluator requests use the former instead of building the path from the bulk
  base, and the file id is URL-encoded as one path segment by a small local
  encoder rather than a new crate. send_evaluated_file stays on the bulk base.
- An error body carrying a "detail" string (Django REST framework, used by the
  Inbox Placement Tester) returns that text.

Removes validate_s3_file, score_s3_file, export_s3_file and list_s3_files along
with the S3ListFileEntry struct, the post_bulk_root_form helper, tests and
changelog entry: https://www.zerobounce.net/docs/ does not document those
endpoints, and AGENTS.md now records that the SDK covers only documented ones.

The offline test client now also points list_evaluator_url and
inbox_placement_url at the fake server; without that the suite reached the live
API, which is how the list evaluator test first failed.

Tests: 130 pass across the four suites. Clippy reports no errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They are documented, at
https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, which
lists listS3files, validateS3file, scoreS3file, exportS3file,
emailFinderS3file and domainSearchS3file on bulkapi.zerobounce.net. The
previous commit removed them on the incorrect basis that the public docs do
not cover them; only the main API reference was considered.

Restores validate_s3_file, score_s3_file, export_s3_file and list_s3_files,
the S3ListFileEntry struct, the post_bulk_root_form helper, the offline tests
and the changelog entry. The parity work in the previous commit is untouched.

AGENTS.md keeps the rule that the SDK covers only documented endpoints and
now points at where the S3 documentation lives.

Not added here: emailFinderS3file and domainSearchS3file, and the SFTP
variants listed on the Integrations index, are documented but have never been
part of this SDK.

Tests: 130 pass across the four suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports b0444f8 from the Python SDK. These are documented but had never been in
this SDK:

- POST /emailFinderS3file and /domainSearchS3file, from
  https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api
- POST /validateSFTPfile, /scoreSFTPfile, /emailFinderSFTPfile,
  /domainSearchSFTPfile and /exportSFTPfile, from
  https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api

All six file jobs share bulk_root_file_job, which sends has_header_row and
remove_duplicates as the strings true/false and leaves remove_duplicates and
return_url out until set.

Two shapes differ from the S3 ones and are easy to get wrong:
- validate_sftp_file's file_name is optional, so it takes Option<&str>; None
  uses the input bucket from the account's settings. Every other job requires
  a name, and a blank one is still rejected.
- export_sftp_file takes export_path, not the export_bucket that
  export_s3_file uses, and None uses the export path from those settings.

The existing S3 methods are untouched, so no signature changes for callers.

Tests: 132 pass. Offline only — the fake server's default success body already
covers these routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The API now returns ai_agent_mailbox under both the valid and catch-all
statuses. No new status was added.

Adds the AiAgentMailbox variant and its from_str arm following
RoleBasedCatchAll, plus SS_AI_AGENT_MAILBOX alongside the other sub_status
string constants.

Unknown values were already handled best-in-class here: sub_status is kept as
a String and sub_status_enum() falls back to UnknownValue(String), so an
unrecognised value is preserved rather than dropped. A test pins that
round-trip alongside the two new ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified against the live API; where the API and the docs disagree, the
API wins and the call site says so.

- validatebatch takes timeout as a JSON string. Sent as a number the API
  rejects the whole batch with the account-level "Invalid API Key or
  your account ran out of credits".
- The Inbox Placement Tester authenticates with a Bearer header, not an
  api_key parameter.
- send_evaluated_file takes the list evaluator's file_id as a form
  field, not a file upload. Its signature changes accordingly.
- The S3 and SFTP jobs require file_name and return_url, validateSFTPfile
  included, although the docs list file_name as optional for that one.

{"success": false} on an HTTP 200 is a failure as well: the API answers
an unknown file id that way, and two tests had pinned the wrong
behaviour. ZBError::ApiError carries the HTTP status, reachable through
ZBError::status_code(), so callers can tell a 403 from Cloudflare apart
from a 400/404 from the API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Warmup API is a separate product: its own host, its own team-scoped
key, and an API-Key header rather than an api_key parameter. ZeroBounce
stays the validation client; ZeroBounceWarmup covers all 26 documented
endpoints, including the two CSV downloads.

Two live findings are recorded at the call site: check-domain-dmarc
takes an email address, not the domain the docs describe, and a failed
SMTP login comes back as an HTTP 500. The SMTP username and password are
masked out of every error from that method.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Calls every SDK method against the real API. It sits behind the `live`
feature, so `cargo test` and CI never compile or run it; running it
takes `cargo test --features live --test live_api` and keys in the
environment.

Free calls run by default: sandbox addresses, reads, and requests
expected to fail that still prove the API accepts the payload. The free
tier asserts the credit balance did not move. ZEROBOUNCE_LIVE_SPEND=1
adds the billable calls and a bulk round trip that deletes its own job;
ZEROBOUNCE_LIVE_CHANGES=1 adds account changes, each undone in cleanup
even when the test fails.

Nothing the API cannot undo is ever called: no Inbox Placement test, no
Inbox Placement webhook, no ZB-managed Warmup onboarding. Keys come from
the environment and are redacted out of every message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README gains a Warmup API section and the opt-in live suite with its two
flags. AGENTS.md records where the live API and the docs disagree, the
rule that the API wins and the call site says so, and the release rule
that the live suite is run by hand before every release. CHANGELOG lists
every user-visible change under [Unreleased].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread tests/offline_warmup.rs Fixed
Comment thread tests/offline_warmup.rs Fixed
Comment thread tests/offline_warmup.rs Fixed
franciscbalint and others added 2 commits October 24, 2026 16:27
Found by running the live suite in a container, which does reach both
crates.io and the API.

The API returns only the counters that apply to the account and the date range
asked for, and an ordinary response carries neither sub_status_gold nor
sub_status_role_based_accept_all. Both were required fields, so serde failed
the whole response — reported as "ZeroBounce returned a response that is not
valid JSON", because the body is over 1000 characters and carries no message
or error key.

Every counter on ApiUsage now defaults to 0 when absent, so a counter the API
adds or drops later is ignored rather than fatal.
sub_status_leading_period_removed and sub_status_alias_address, which the API
does return, are now read instead of discarded.

The iOS SDK had the same bug, from the same two fields.

Offline: 162 tests, 0 failures. Live: 40 passed, 0 failed, 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodeQL flags a string literal passed as a password as a hard-coded
cryptographic value, in a test as much as anywhere else. Nothing here was ever
a real secret, so the three fixtures are assembled at run time instead.

The redaction test gains from it: its canned 500 body now echoes the very
credentials that were sent, so asserting the message does not contain them
proves the client strips them, rather than that the body happened not to
mention them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread tests/offline_warmup.rs Fixed
Comment thread tests/offline_warmup.rs Fixed
Comment thread tests/offline_warmup.rs Fixed
The previous attempt still built the value from literals with format!, which
CodeQL folds to a constant, so the hard-coded-credential alerts simply moved
to the new lines. The value now comes from the process id and the clock, with
no literal anywhere in the password dataflow.

It stays distinctive enough for the redaction test, which asserts the client
strips the credential out of an error message echoing it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@franciscbalint
franciscbalint merged commit f9e0482 into main Oct 24, 2026
6 checks passed
@franciscbalint
franciscbalint deleted the feature/api-parity-p1-validate branch October 24, 2026 19:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants