From 0b932019ba599221ceb7c6f105f5e3b9828bdf99 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:20:33 +0400 Subject: [PATCH] fix: match what the live API accepts, from calling every endpoint Every SDK method was called against the live API (both clients, every endpoint, success or failure). Where the API and the docs disagree, the SDK now follows the API: - validate_batch: timeout is sent as a string. As a JSON number the API rejected the whole batch with "Invalid API Key or your account ran out of credits". - send_evaluated_file(file_id): takes the list evaluator's file_id. The docs describe a file upload, which the API rejects ("file_id: This field is required"). Verified: the evaluator's id starts a validation job. - S3 / SFTP jobs require return_url (the API answers "Provided return URL is not permitted: None" without one) and validate_sftpfile requires file_name. With them, every job passes payload validation and stops only at the account's missing AWS / SFTP setup. - ZeroBounceWarmup.check_domain_dmarc takes an email address; the API rejects a bare domain ("Please provide a valid email address"). - A JSON-list error body (["In-progress tests cannot be deleted."]) reads as its first message. - ZBApiException.status_code is set by the main client too (403 from Cloudflare, 400/404 from the API). Docs, AGENTS.md (new "live API wins over the docs" rule with the known cases) and CHANGELOG updated. 130 offline tests on Python 3.12 and 3.9; all 63 doc examples run offline. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- CHANGELOG.md | 11 +++--- README.md | 10 +++-- documentation.md | 10 +++-- src/zerobouncesdk/zb_warmup.py | 15 ++++++-- src/zerobouncesdk/zerobouncesdk.py | 59 ++++++++++++++++-------------- tests/test_offline_api.py | 59 ++++++++++++++++++------------ tests/test_offline_edges.py | 33 +++++++++++++++++ tests/test_warmup_offline.py | 7 ++-- tests/zero_bounce_test_case.py | 4 +- 10 files changed, 138 insertions(+), 74 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a9f20ac..a59671e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ CI (`.github/workflows/sdk_ci.yml`) runs `ruff check`, `compileall`, then `pytho - **Column indexes are 1-based, and the SDK does not add 1.** Pass the API's index. `0` is a real value and is sent. `None` on an optional column (such as `first_name_column`) omits the field. `send_file`'s email column is required and is sent as given, including `0`. `domain_column` and the list-evaluator email column are required: `None` is rejected, `0` is sent. The bulk email finder also needs `first_name_column` or `full_name_column`. - **Optional parameters are omitted when unset.** `timeout`, `activity_data`, `verify_plus`, `domain_info`, `greylist_processing`, `callback_url`, and `allow_phase_2` are not sent until the caller sets them. Explicit `False` is sent as `false`. `ip_address=None` is omitted; `""` is sent. - **Booleans on form and multipart bodies must be the strings `true` and `false`.** A Python `bool` passed to `requests` becomes `True` or `False`, and the API will not treat that as a boolean. `_form_data` converts them. JSON bodies (`validate_batch`, the Inbox Placement Tester, the Warmup API) keep JSON booleans. -- **`allow_phase_2` is validation `send_file` only.** Scoring `send_file` and the S3 / SFTP jobs do not send it; those jobs send only their documented `file_name`, `has_header_row`, `remove_duplicates`, and `return_url`. `activity_data` on get-file is validation `get_file` only. +- **`allow_phase_2` is validation `send_file` only.** Scoring `send_file` and the S3 / SFTP jobs do not send it; those jobs send only `file_name`, `has_header_row`, `remove_duplicates`, and `return_url`, and require `file_name`, `has_header_row` and `return_url` because the live API rejects jobs without them. `activity_data` on get-file is validation `get_file` only. - **`validate_batch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. It follows `base_url`. - **Failures raise.** `ZBClientException` means the arguments were rejected and no request was sent. `ZBApiException` means the API failed. The message must be short: no raw HTML, no megabyte body, no `JSONDecodeError` / `Expecting value` text, and no `Error: Error:` prefix. An account-level `validate_batch` error (`email_address` `all`) raises. A per-address error stays on the result's `errors` list. `get_credits` with an invalid key returns `Credits` `-1`; that endpoint does not send an `error` field. - **Downloads are buffered unless `stream=True`.** The streamed path sniffs the first 64 KB for an error body, writes to a `.part` temporary file next to `download_path`, and moves it into place only when complete. Keep both paths' error checks identical (`_raise_if_download_error`). @@ -50,6 +50,8 @@ CI (`.github/workflows/sdk_ci.yml`) runs `ruff check`, `compileall`, then `pytho - **Do not commit `.env`.** It is gitignored and may hold a real key. Tests must not call the live API. - Match the surrounding style. Do not reformat unrelated code. +- **The live API wins over the docs.** Where they disagree, follow what the API accepts and say so in the docstring. Known cases: `validate_batch` `timeout` must be a JSON string; `send_evaluated_file` takes the evaluator's `file_id`, not an upload; S3 / SFTP jobs need `return_url` (and `validateSFTPfile` needs `file_name`); Warmup `check-domain-dmarc` needs an email address, not a domain. Check a new endpoint against the live API (`tests/zero_bounce_integration_test.py` style, sandbox addresses where possible) before release. + ## 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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 72277cd..0c73c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,23 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `validate` takes `timeout` (3–60 seconds), `activity_data`, `verify_plus`, and `use_post` (form-encoded `POST /validate`). -- `validate_batch` takes `timeout` (10–120 seconds), `activity_data`, and `verify_plus`. +- `validate_batch` takes `timeout` (10–120 seconds; sent as a string, which the live API requires), `activity_data`, and `verify_plus`. - `ZBValidateResponse` reads `active_in_days` and `active_first_seen`. - `score_email` (`GET /scoring`) and `score_email(..., use_post=True)` (form-encoded `POST /scoring`). - Bulk email finder (`send_email_finder_file`, `email_finder_file_status`, `email_finder_get_file`, `email_finder_delete_file`, plus stream upload). - Bulk domain search (`send_domain_search_file`, `domain_search_file_status`, `domain_search_get_file`, `domain_search_delete_file`, plus stream upload). -- List evaluator (`send_list_evaluator_file`, `get_list_evaluator_result`, `send_evaluated_file`). Uploads and results return `ZBListEvaluatorResponse` (`file_id`, `status`, `progress`, the percentages, `abuse`, `do_not_mail`, `spam_trap`). +- List evaluator (`send_list_evaluator_file`, `get_list_evaluator_result`, `send_evaluated_file`). `send_evaluated_file(file_id)` submits a finished evaluation for validation by the evaluator's `file_id`, as the live API requires (the docs describe a file upload). Uploads and results return `ZBListEvaluatorResponse` (`file_id`, `status`, `progress`, the percentages, `abuse`, `do_not_mail`, `spam_trap`). - Filters (`add_filter`, `list_filters`, `delete_filter`). `rule` must be `allow` or `block` and `target` `email`, `domain`, `mx`, or `tld`. -- Amazon S3 bulk (`list_s3files`, `validate_s3file`, `score_s3file`, `email_finder_s3file`, `domain_search_s3file`, `export_s3file`) and SFTP/FTP bulk (`validate_sftpfile`, `score_sftpfile`, `email_finder_sftpfile`, `domain_search_sftpfile`, `export_sftpfile`). Each job sends the documented `file_name`, `has_header_row` (required), `remove_duplicates`, and `return_url`. The export bucket and path are optional; without them your account settings are used. +- Amazon S3 bulk (`list_s3files`, `validate_s3file`, `score_s3file`, `email_finder_s3file`, `domain_search_s3file`, `export_s3file`) and SFTP/FTP bulk (`validate_sftpfile`, `score_sftpfile`, `email_finder_sftpfile`, `domain_search_sftpfile`, `export_sftpfile`). Each job sends `file_name`, `has_header_row`, `remove_duplicates`, and `return_url`; `file_name`, `has_header_row`, and `return_url` are required because the live API rejects jobs without them. The export bucket and path are optional; without them your account settings are used. - `validate` takes `domain_info` (website availability and WHOIS), `greylist_processing`, and `callback_url` (requires `greylist_processing=True`). `ZBValidateResponse` reads `domain_website_exists` and `domain_registrant_company_name`, and `message` while a greylist retry is still in progress. - Inbox Placement Tester (`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`) on `bulkapi.zerobounce.net/api/ipt`. Its `detail` error messages raise `ZBApiException` with that text. - `get_credits`, `get_api_usage`, `get_activity`, `find_email_format`, and `find_domain` take `use_post=True` (form-encoded `POST`, which the API accepts since September 2026). - `ZBValidateSubStatus.ai_agent_mailbox`, documented for `valid` and `catch-all` results. - `ZBGetActivityResponse.active_first_seen`, and `ZBGetApiUsageResponse` counts for `leading_period_removed` and `alias_address`. -- `ZeroBounceWarmup`, a client for the Warmup API (https://www.zerobounce.net/docs/warmup-api) with a method for each of its 26 endpoints. It sends a Warmup API key in the `API-Key` header to `https://warmup-api.zerobounce.net` (or the base URL you pass), checks the documented ranges before sending, returns `None` for 204 and empty successes, and returns CSV text for the two downloads. +- `ZeroBounceWarmup`, a client for the Warmup API (https://www.zerobounce.net/docs/warmup-api) with a method for each of its 26 endpoints. `check_domain_dmarc` takes an email address, as the live API requires. It sends a Warmup API key in the `API-Key` header to `https://warmup-api.zerobounce.net` (or the base URL you pass), checks the documented ranges before sending, returns `None` for 204 and empty successes, and returns CSV text for the two downloads. - `get_file`, `scoring_get_file`, `email_finder_get_file`, and `domain_search_get_file` take `stream=True` to write a result file in chunks instead of reading it into memory first. The file appears at `download_path` only once complete, so a failed download leaves an existing file untouched and no partial file behind. Without the flag, downloads behave as before. - A `UserWarning`, once per client, when an EU or US `base_url` is set but a request goes to `bulkapi.zerobounce.net` (bulk, scoring and finder files, the list evaluator, S3/SFTP, the Inbox Placement Tester): ZeroBounce documents no regional bulk host. -- `ZBApiException.status_code`: the HTTP status, set by `ZeroBounceWarmup` so callers can back off on 429. +- `ZBApiException.status_code`: the HTTP status of a failed response, from both clients (for example 429 from the Warmup API, 403 when Cloudflare blocks a request). - `LIST_EVALUATOR_BASE_URL` and `INBOX_PLACEMENT_BASE_URL` set where the list evaluator and Inbox Placement Tester requests go, like `BULK_BASE_URL` for the other bulk files. ### Changed @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Importing the SDK works on Python 3.7 to 3.9 again. `ZeroBounce.__init__` was annotated with `timedelta | None`, which raised `TypeError` at import on those versions. - `find_domain` and `guess_format` raise `ZBApiException` for a lowercase `message` error instead of `KeyError`. - A validation error body `{"detail": [{"loc": ..., "msg": ...}]}` raises with the field and message instead of the raw JSON. +- An error body that is a JSON list of messages (`["In-progress tests cannot be deleted."]`) raises with the first message instead of the raw JSON. - An unknown `email_confidence` or `confidence` value is `None` with a warning instead of a `ValueError`. - `ZBValidateResponse` reads `free_email`, `mx_found`, and `catchall_domain` sent as the strings `"true"` / `"false"` as booleans (the documented samples send `mx_found` as a string). - `processed_at` without fractional seconds, and `get_api_usage` dates in the documented `yyyy/mm/dd` form, parse instead of raising `ValueError`. A date in another format is kept as the original string, with a warning. diff --git a/README.md b/README.md index d25a3e7..704b67a 100644 --- a/README.md +++ b/README.md @@ -466,7 +466,7 @@ except ZBException as e: ```python sent = zero_bounce.send_list_evaluator_file("./list.csv", email_address_column=1) # at least 100 emails result = zero_bounce.get_list_evaluator_result(sent.file_id) # .status (processing/finished), .progress, .invalid_percentage, ... -zero_bounce.send_evaluated_file("./list.csv") # submit the evaluated list for validation +job = zero_bounce.send_evaluated_file(sent.file_id) # validate the evaluated list; job.file_id -> file_status / get_file ``` ### Allowlist and blocklist filters @@ -492,7 +492,7 @@ try: zero_bounce.validate_s3file(name, True, return_url="https://example.com/done") # Also: score_s3file, email_finder_s3file, domain_search_s3file (file_name, has_header_row, ...) - zero_bounce.score_sftpfile("my_file.txt", True) # file name on your SFTP/FTP server + zero_bounce.score_sftpfile("my_file.txt", True, return_url="https://example.com/done") # file on your SFTP/FTP server # Also: validate_sftpfile, email_finder_sftpfile, domain_search_sftpfile # When the file is processed, export it back with its file ID (from return_url's callback, @@ -503,7 +503,7 @@ except ZBException as e: print("ZeroBounce S3/SFTP error: " + str(e)) ``` -`has_header_row` is required; `remove_duplicates` (default `True` on the API) and `return_url` are optional. The documented success response is `success` and `message`, without a file ID. +`file_name`, `has_header_row` and `return_url` are required: the live API rejects a job without a return URL, and `validate_sftpfile` without a file name, although the docs mark them optional. `remove_duplicates` (default `True` on the API) is optional. The documented success response is `success` and `message`, without a file ID. ### Inbox Placement Tester @@ -553,7 +553,7 @@ try: managed_sending={"weekdays_only": True, "timezone_offset": 3.0, "warmup_start_date": "2026-11-02"}, ) print(warmup.get_sending_account("sender@example.com")) - print(warmup.check_domain_dmarc("example.com", dkim_selector="default")) + print(warmup.check_domain_dmarc("sender@example.com", dkim_selector="default")) # an email address, not a domain warmup.set_engagement_rule("sender@example.com", 1) # returns None (HTTP 204) warmup.create_custom_engagement("sender@example.com", reply_pct=20, important_pct=25, click_pct=25) print(warmup.get_statistics_per_day("sender@example.com", "2026-10-01", "2026-10-14")) @@ -572,6 +572,8 @@ Every documented endpoint has a method: sending accounts (`list_sending_accounts ### Other hosts +`test_smtp_configuration` currently gets HTTP 500 from the API whenever the SMTP login fails, so a `ZBApiException` with `status_code` 500 there means "the credentials did not work". + `base_url` sets the host of the single-request API (validate, scoring, email finder, filters). The bulk APIs have their own class attributes, which you can set on an instance. ZeroBounce documents no regional bulk host, so with an EU or US `base_url` the bulk calls still go to `bulkapi.zerobounce.net`; the client warns once (`UserWarning`) when that happens, unless you set the bulk URLs yourself. ```python diff --git a/documentation.md b/documentation.md index cfa4325..0751148 100644 --- a/documentation.md +++ b/documentation.md @@ -448,7 +448,7 @@ except ZBException as e: ```python sent = zero_bounce.send_list_evaluator_file("./list.csv", email_address_column=1) # at least 100 emails result = zero_bounce.get_list_evaluator_result(sent.file_id) # .status (processing/finished), .progress, .invalid_percentage, ... -zero_bounce.send_evaluated_file("./list.csv") # submit the evaluated list for validation +job = zero_bounce.send_evaluated_file(sent.file_id) # validate the evaluated list; job.file_id -> file_status / get_file ``` ##### Allowlist and blocklist filters @@ -474,7 +474,7 @@ try: zero_bounce.validate_s3file(name, True, return_url="https://example.com/done") # Also: score_s3file, email_finder_s3file, domain_search_s3file (file_name, has_header_row, ...) - zero_bounce.score_sftpfile("my_file.txt", True) # file name on your SFTP/FTP server + zero_bounce.score_sftpfile("my_file.txt", True, return_url="https://example.com/done") # file on your SFTP/FTP server # Also: validate_sftpfile, email_finder_sftpfile, domain_search_sftpfile # When the file is processed, export it back with its file ID (from return_url's callback, @@ -485,7 +485,7 @@ except ZBException as e: print("ZeroBounce S3/SFTP error: " + str(e)) ``` -`has_header_row` is required; `remove_duplicates` (default `True` on the API) and `return_url` are optional. The documented success response is `success` and `message`, without a file ID. +`file_name`, `has_header_row` and `return_url` are required: the live API rejects a job without a return URL, and `validate_sftpfile` without a file name, although the docs mark them optional. `remove_duplicates` (default `True` on the API) is optional. The documented success response is `success` and `message`, without a file ID. ##### Inbox Placement Tester @@ -535,7 +535,7 @@ try: managed_sending={"weekdays_only": True, "timezone_offset": 3.0, "warmup_start_date": "2026-11-02"}, ) print(warmup.get_sending_account("sender@example.com")) - print(warmup.check_domain_dmarc("example.com", dkim_selector="default")) + print(warmup.check_domain_dmarc("sender@example.com", dkim_selector="default")) # an email address, not a domain warmup.set_engagement_rule("sender@example.com", 1) # returns None (HTTP 204) warmup.create_custom_engagement("sender@example.com", reply_pct=20, important_pct=25, click_pct=25) print(warmup.get_statistics_per_day("sender@example.com", "2026-10-01", "2026-10-14")) @@ -554,6 +554,8 @@ Every documented endpoint has a method: sending accounts (`list_sending_accounts ##### Other hosts +`test_smtp_configuration` currently gets HTTP 500 from the API whenever the SMTP login fails, so a `ZBApiException` with `status_code` 500 there means "the credentials did not work". + `base_url` sets the host of the single-request API (validate, scoring, email finder, filters). The bulk APIs have their own class attributes, which you can set on an instance. ZeroBounce documents no regional bulk host, so with an EU or US `base_url` the bulk calls still go to `bulkapi.zerobounce.net`; the client warns once (`UserWarning`) when that happens, unless you set the bulk URLs yourself. ```python diff --git a/src/zerobouncesdk/zb_warmup.py b/src/zerobouncesdk/zb_warmup.py index 49a3c5a..85bca36 100644 --- a/src/zerobouncesdk/zb_warmup.py +++ b/src/zerobouncesdk/zb_warmup.py @@ -371,12 +371,17 @@ def get_from_address_statistics(self, from_address: str): "GET", "/from-address-statistics", params={"from_address": self._required(from_address, "from_address")} ) - def check_domain_dmarc(self, domain: str, dkim_selector: Optional[str] = None): - """Check a domain's DNS records (GET /check-domain-dmarc). + def check_domain_dmarc(self, from_address: str, dkim_selector: Optional[str] = None): + """SPF, DKIM and DMARC status of a from-address's domain (GET /check-domain-dmarc). - The API names the domain parameter ``from_address``; pass a domain such as ``example.com``. + Pass an email address: the live API answers "Please provide a valid email address" for a bare + domain, although the docs describe a domain. Returns ``SPF_status``, ``DKIM_status``, + ``DMARC_status`` and the ``dkim_selector`` that was checked. """ - params = {"from_address": self._required(domain, "domain")} + self._required(from_address, "from_address") + if "@" not in from_address: + raise ZBClientException("Invalid parameter: from_address (an email address, e.g. sender@example.com)") + params = {"from_address": from_address} if dkim_selector is not None: params["dkim_selector"] = self._required(dkim_selector, "dkim_selector") return self._json("GET", "/check-domain-dmarc", params=params) @@ -385,6 +390,8 @@ def test_smtp_configuration(self, from_address: str, username: str, password: st """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``. + The live API currently answers a failed SMTP login with HTTP 500, raised as ``ZBApiException`` + with ``status_code`` 500. """ if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: raise ZBClientException("Invalid parameter: port (must be 1 to 65535)") diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 38c3fbd..ce493ea 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -119,6 +119,9 @@ def _message_from_body(body: str, status_code: int) -> str: loc = value[0].get("loc") field = ".".join(str(part) for part in loc[1:]) if isinstance(loc, list) and len(loc) > 1 else "" return f"{field}: {value[0]['msg']}" if field else str(value[0]["msg"]) + # e.g. ["In-progress tests cannot be deleted."] from the Inbox Placement Tester + if isinstance(parsed, list) and parsed and isinstance(parsed[0], str) and parsed[0].strip(): + return _strip_error_prefix(parsed[0]) if len(trimmed) > 500: return "ZeroBounce returned an error (HTTP %s)." % status_code return trimmed @@ -314,10 +317,10 @@ def _read_json_payload(self, response): content_type = headers.get("Content-Type") or headers.get("content-type") or "" if 300 <= status < 400: raise ZBApiException( - self._redact("ZeroBounce returned a redirect, which this client does not follow.") + self._redact("ZeroBounce returned a redirect, which this client does not follow."), status_code=status ) if status > 299 or _looks_like_html(body, content_type) or not body.strip(): - raise ZBApiException(self._redact(_message_from_body(body, status))) + raise ZBApiException(self._redact(_message_from_body(body, status)), status_code=status) try: payload = response.json() except ValueError: @@ -351,16 +354,16 @@ def _raise_if_download_error(self, response, body_str: str, content_type: str) - status = response.status_code if 300 <= status < 400: raise ZBApiException( - self._redact("ZeroBounce returned a redirect, which this client does not follow.") + self._redact("ZeroBounce returned a redirect, which this client does not follow."), status_code=status ) if not (body_str or "").strip(): - raise ZBApiException(self._redact(_message_from_body(body_str, status))) + raise ZBApiException(self._redact(_message_from_body(body_str, status)), status_code=status) if ( status > 299 or _looks_like_html(body_str, content_type) or self._should_treat_get_file_body_as_error(body_str, content_type) ): - raise ZBApiException(self._redact(_message_from_body(body_str, status))) + raise ZBApiException(self._redact(_message_from_body(body_str, status)), status_code=status) def _get(self, url, response_class, params=None): self._checked_url(url) @@ -838,7 +841,9 @@ def validate_batch( ], } if timeout is not None: - payload["timeout"] = timeout + # The live API rejects a JSON number here (it answers with the account-level "Invalid API Key + # or your account ran out of credits" error) and accepts the value as a string. + payload["timeout"] = str(timeout) if activity_data is not None: payload["activity_data"] = activity_data if verify_plus is not None: @@ -1472,16 +1477,16 @@ def get_list_evaluator_result(self, file_id: str) -> ZBListEvaluatorResponse: ) ) - def send_evaluated_file(self, file_path: str) -> ZBSendFileResponse: - data = {"api_key": self._api_key} - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "text/csv")} - return self._post( - f"{self.BULK_BASE_URL}/send-evaluated-file/", - ZBSendFileResponse, - data=data, - files=files, - ) + def send_evaluated_file(self, file_id: str) -> ZBSendFileResponse: + """Submit a list the list evaluator has finished for bulk validation (POST /send-evaluated-file/). + + ``file_id`` is the list evaluator's ``file_id``. The live API takes that ID, not a new upload + (the docs describe a file upload, which the API rejects with "file_id: This field is required"). + The result's ``file_id`` is the new validation job, followed with ``file_status`` and ``get_file``. + """ + if not file_id or not file_id.strip(): + raise ZBClientException("Empty parameter: file_id") + return self._post_form(f"{self.BULK_BASE_URL}/send-evaluated-file/", ZBSendFileResponse, {"file_id": file_id}) # Priority 3 — custom filters (main API) @@ -1615,11 +1620,17 @@ def _bulk_root_file_job( *, file_name_required: bool = True, ) -> ZBSendFileResponse: - """POST one of the documented S3 / SFTP file jobs on the bulkapi root.""" + """POST one of the documented S3 / SFTP file jobs on the bulkapi root. + + The live API rejects a job without ``return_url`` ("Provided return URL is not permitted: None") and a + ``validateSFTPfile`` without ``file_name``, although the docs mark both optional; so both are required here. + """ if (file_name is None and file_name_required) or (file_name is not None and not file_name.strip()): raise ZBClientException("Empty parameter: file_name") if has_header_row is None: raise ZBClientException("Empty parameter: has_header_row") + if not return_url or not str(return_url).strip(): + raise ZBClientException("Empty parameter: return_url (the API rejects these jobs without one)") fields = {"has_header_row": has_header_row} if file_name is not None: fields["file_name"] = file_name @@ -1660,22 +1671,14 @@ def domain_search_s3file( def validate_sftpfile( self, - file_name: Optional[str], + file_name: str, has_header_row: bool, remove_duplicates: Optional[bool] = None, return_url: Optional[str] = None, ) -> ZBSendFileResponse: - """Validate a file from your SFTP/FTP server (POST /validateSFTPfile). - - The docs list ``file_name`` as optional for this endpoint; pass ``None`` to leave it out. - """ + """Validate a file from your SFTP/FTP server (POST /validateSFTPfile). ``file_name`` is e.g. ``my_file.txt``.""" return self._bulk_root_file_job( - "/validateSFTPfile", - file_name, - has_header_row, - remove_duplicates, - return_url, - file_name_required=False, + "/validateSFTPfile", file_name, has_header_row, remove_duplicates, return_url ) def score_sftpfile( diff --git a/tests/test_offline_api.py b/tests/test_offline_api.py index 039cfb5..fdb26d0 100644 --- a/tests/test_offline_api.py +++ b/tests/test_offline_api.py @@ -330,21 +330,21 @@ def test_every_public_method_hits_the_right_endpoint(self): ("send_list_evaluator_file", lambda: self.client.send_list_evaluator_file(str(csv), 1), "POST", "bulkapi.zerobounce.net", "/v2/listevaluator/"), ("send_list_evaluator_file_stream", lambda: self.client.send_list_evaluator_file_stream(BytesIO(b"a@b.com\n"), "l.csv", 1), "POST", "bulkapi.zerobounce.net", "/v2/listevaluator/"), ("get_list_evaluator_result", lambda: self.client.get_list_evaluator_result("file-9"), "GET", "bulkapi.zerobounce.net", "/v2/listevaluator/file-9/"), - ("send_evaluated_file", lambda: self.client.send_evaluated_file(str(csv)), "POST", "bulkapi.zerobounce.net", "/v2/send-evaluated-file/"), + ("send_evaluated_file", lambda: self.client.send_evaluated_file("file-9"), "POST", "bulkapi.zerobounce.net", "/v2/send-evaluated-file/"), ("add_filter", lambda: self.client.add_filter("allow", "domain", "example.com"), "POST", "api.zerobounce.net", "/v2/filters/add"), ("list_filters", lambda: self.client.list_filters(), "GET", "api.zerobounce.net", "/v2/filters/list"), ("list_filters_post", lambda: self.client.list_filters(use_post=True), "POST", "api.zerobounce.net", "/v2/filters/list"), ("delete_filter", lambda: self.client.delete_filter("allow", "domain", "example.com"), "POST", "api.zerobounce.net", "/v2/filters/delete"), ("list_s3files", lambda: self.client.list_s3files(bucket="b", search="a"), "GET", "bulkapi.zerobounce.net", "/listS3files"), - ("validate_s3file", lambda: self.client.validate_s3file("lists/jan.csv", True), "POST", "bulkapi.zerobounce.net", "/validateS3file"), - ("score_s3file", lambda: self.client.score_s3file("lists/jan.csv", True), "POST", "bulkapi.zerobounce.net", "/scoreS3file"), + ("validate_s3file", lambda: self.client.validate_s3file("lists/jan.csv", True, return_url="https://example.com/cb"), "POST", "bulkapi.zerobounce.net", "/validateS3file"), + ("score_s3file", lambda: self.client.score_s3file("lists/jan.csv", True, return_url="https://example.com/cb"), "POST", "bulkapi.zerobounce.net", "/scoreS3file"), ("export_s3file", lambda: self.client.export_s3file("file-9", "export-bucket"), "POST", "bulkapi.zerobounce.net", "/exportS3file"), - ("email_finder_s3file", lambda: self.client.email_finder_s3file("lists/people.csv", True), "POST", "bulkapi.zerobounce.net", "/emailFinderS3file"), - ("domain_search_s3file", lambda: self.client.domain_search_s3file("lists/domains.csv", True), "POST", "bulkapi.zerobounce.net", "/domainSearchS3file"), - ("validate_sftpfile", lambda: self.client.validate_sftpfile("my_file.txt", True), "POST", "bulkapi.zerobounce.net", "/validateSFTPfile"), - ("score_sftpfile", lambda: self.client.score_sftpfile("my_file.txt", True), "POST", "bulkapi.zerobounce.net", "/scoreSFTPfile"), - ("email_finder_sftpfile", lambda: self.client.email_finder_sftpfile("my_file.txt", True), "POST", "bulkapi.zerobounce.net", "/emailFinderSFTPfile"), - ("domain_search_sftpfile", lambda: self.client.domain_search_sftpfile("my_file.txt", True), "POST", "bulkapi.zerobounce.net", "/domainSearchSFTPfile"), + ("email_finder_s3file", lambda: self.client.email_finder_s3file("lists/people.csv", True, return_url="https://example.com/cb"), "POST", "bulkapi.zerobounce.net", "/emailFinderS3file"), + ("domain_search_s3file", lambda: self.client.domain_search_s3file("lists/domains.csv", True, return_url="https://example.com/cb"), "POST", "bulkapi.zerobounce.net", "/domainSearchS3file"), + ("validate_sftpfile", lambda: self.client.validate_sftpfile("my_file.txt", True, return_url="https://example.com/cb"), "POST", "bulkapi.zerobounce.net", "/validateSFTPfile"), + ("score_sftpfile", lambda: self.client.score_sftpfile("my_file.txt", True, return_url="https://example.com/cb"), "POST", "bulkapi.zerobounce.net", "/scoreSFTPfile"), + ("email_finder_sftpfile", lambda: self.client.email_finder_sftpfile("my_file.txt", True, return_url="https://example.com/cb"), "POST", "bulkapi.zerobounce.net", "/emailFinderSFTPfile"), + ("domain_search_sftpfile", lambda: self.client.domain_search_sftpfile("my_file.txt", True, return_url="https://example.com/cb"), "POST", "bulkapi.zerobounce.net", "/domainSearchSFTPfile"), ("export_sftpfile", lambda: self.client.export_sftpfile("file-9"), "POST", "bulkapi.zerobounce.net", "/exportSFTPfile"), ("guess_format", lambda: self.client.guess_format("example.com", first_name="Ada"), "GET", "api.zerobounce.net", "/v2/guessformat"), ("find_email_format", lambda: self.client.find_email_format("Ada", domain="example.com"), "GET", "api.zerobounce.net", "/v2/guessformat"), @@ -379,7 +379,8 @@ def test_validate_batch_posts_json_to_the_api_host(self): batch = recorded.json["email_batch"] self.assertEqual(batch[0]["email_address"], "ada@example.com") self.assertEqual(batch[0]["ip_address"], "0.0.0.0") - self.assertEqual(recorded.json["timeout"], 30) + # A string: the live API rejects the batch when timeout is a JSON number. + self.assertEqual(recorded.json["timeout"], "30") self.assertIs(recorded.json["activity_data"], False) self.assertIs(recorded.json["verify_plus"], True) @@ -453,7 +454,7 @@ def test_list_evaluator_base_url_is_separate_and_file_id_is_encoded(self): self.assertIn("/api/validation/list-evaluator/a%2Fb%3Fc/", recorded.url) self.transport.requests.clear() - client.send_evaluated_file(str(self._csv())) + client.send_evaluated_file("ev-1") self.assertEqual(self._only().path, "/v2/send-evaluated-file/") def test_inbox_placement_create_sends_camel_case_json(self): @@ -511,9 +512,13 @@ def test_inbox_placement_list_filters(self): self.assertEqual(recorded.query["page_size"], "100") def test_s3_and_sftp_file_jobs_send_the_documented_fields(self): - self.client.email_finder_s3file("lists/people.csv", False) + self.client.email_finder_s3file("lists/people.csv", False, return_url="https://example.com/cb") recorded = self._only() - self.assertEqual(recorded.form, {"api_key": "client-key", "file_name": "lists/people.csv", "has_header_row": "false"}) + self.assertEqual( + recorded.form, + {"api_key": "client-key", "file_name": "lists/people.csv", "has_header_row": "false", + "return_url": "https://example.com/cb"}, + ) self.transport.requests.clear() self.client.domain_search_sftpfile("my_file.txt", True, remove_duplicates=False, return_url="https://hooks.example.com/done") @@ -523,9 +528,16 @@ def test_s3_and_sftp_file_jobs_send_the_documented_fields(self): self.assertEqual(recorded.form["return_url"], "https://hooks.example.com/done") self.assertNotIn("remove_duplicate", recorded.form) - self.transport.requests.clear() - self.client.validate_sftpfile(None, True) - self.assertNotIn("file_name", self._only().form) + # The live API rejects these jobs without return_url, and validateSFTPfile without file_name. + for attempt in ( + lambda: self.client.validate_sftpfile(None, True, return_url="https://example.com/cb"), + lambda: self.client.validate_s3file("lists/jan.csv", True), + lambda: self.client.score_sftpfile("my_file.txt", True, return_url=" "), + ): + self.transport.requests.clear() + with self.assertRaises(ZBClientException): + attempt() + self.assertEqual(self.transport.requests, []) self.transport.requests.clear() self.client.score_s3file("lists/jan.csv", has_header_row=True, return_url="https://hooks.example.com/s") @@ -584,10 +596,11 @@ def test_column_zero_is_sent_and_unset_columns_are_left_out(self): self.assertEqual(self._only().form["email_address_column"], "0") self.transport.requests.clear() - self.client.validate_s3file("a.csv", False, remove_duplicates=False) + self.client.validate_s3file("a.csv", False, remove_duplicates=False, return_url="https://example.com/cb") self.assertEqual( self._only().form, - {"api_key": "client-key", "file_name": "a.csv", "has_header_row": "false", "remove_duplicates": "false"}, + {"api_key": "client-key", "file_name": "a.csv", "has_header_row": "false", "remove_duplicates": "false", + "return_url": "https://example.com/cb"}, ) self.transport.requests.clear() @@ -630,15 +643,15 @@ def test_invalid_arguments_send_no_request(self): lambda: self.client.find_domain(domain="example.com", company_name="Acme"), lambda: self.client.add_filter("", "domain", "example.com"), lambda: self.client.delete_filter("allow", "", "example.com"), - lambda: self.client.validate_s3file(" ", True), + lambda: self.client.validate_s3file(" ", True, return_url="https://example.com/cb"), lambda: self.client.validate_s3file("lists/jan.csv", None), lambda: self.client.score_s3file("lists/jan.csv", None), lambda: self.client.export_s3file(" ", "bucket"), lambda: self.client.export_s3file("file-9", " "), - lambda: self.client.email_finder_s3file(" ", True), + lambda: self.client.email_finder_s3file(" ", True, return_url="https://example.com/cb"), lambda: self.client.domain_search_s3file("lists/d.csv", None), lambda: self.client.score_sftpfile(None, True), - lambda: self.client.validate_sftpfile(" ", True), + lambda: self.client.validate_sftpfile(" ", True, return_url="https://example.com/cb"), lambda: self.client.email_finder_sftpfile("f.txt", None), lambda: self.client.export_sftpfile(" "), lambda: self.client.export_sftpfile("file-9", " "), @@ -688,8 +701,8 @@ def test_account_errors_raise_a_short_message(self): lambda: client.list_filters(), lambda: client.add_filter("block", "domain", "x.com"), lambda: client.list_s3files(), - lambda: client.email_finder_s3file("lists/people.csv", True), - lambda: client.validate_sftpfile("my_file.txt", True), + lambda: client.email_finder_s3file("lists/people.csv", True, return_url="https://example.com/cb"), + lambda: client.validate_sftpfile("my_file.txt", True, return_url="https://example.com/cb"), lambda: client.export_sftpfile("file-9"), lambda: client.find_email_format("Ada", domain="example.com"), lambda: client.find_domain(domain="example.com"), diff --git a/tests/test_offline_edges.py b/tests/test_offline_edges.py index 66df742..c028649 100644 --- a/tests/test_offline_edges.py +++ b/tests/test_offline_edges.py @@ -322,3 +322,36 @@ def test_filter_and_inbox_test_names_are_checked(self): self.transport.requests.clear() self.client.add_filter("block", "mx", "*.example.com") self.assertEqual(self._only().form["target"], "mx") + + +class LiveApiShapesTest(_Offline): + """Shapes seen on the live API that the docs don't describe.""" + + def test_a_json_list_error_reads_as_its_first_message(self): + body = json.dumps(["In-progress tests cannot be deleted."]).encode() + self.transport._reply = lambda recorded: offline._response(400, body, "application/json") + with self.assertRaises(ZBApiException) as caught: + self.client.delete_inbox_placement_test(38890) + self.assertEqual(str(caught.exception), "In-progress tests cannot be deleted.") + self.assertEqual(caught.exception.status_code, 400) + + def test_main_client_errors_carry_the_http_status(self): + self.transport._reply = lambda recorded: offline._response( + 403, b"Access denied | zerobounce.net", "text/html") + with self.assertRaises(ZBApiException) as caught: + self.client.scoring_file_status("file-9") + self.assertEqual(caught.exception.status_code, 403) + + +class SendEvaluatedFileTest(_Offline): + def test_it_sends_the_evaluator_file_id_as_a_form_field(self): + # The live API wants the evaluated list's file_id, not a new upload. + self.client.send_evaluated_file("ev-1") + recorded = self._only() + self.assertEqual(recorded.path, "/v2/send-evaluated-file/") + self.assertEqual(recorded.form, {"api_key": "client-key", "file_id": "ev-1"}) + self.assertEqual(recorded.files, {}) + self.transport.requests.clear() + with self.assertRaises(ZBClientException): + self.client.send_evaluated_file(" ") + self.assertEqual(self.transport.requests, []) diff --git a/tests/test_warmup_offline.py b/tests/test_warmup_offline.py index 1635744..18a4a19 100644 --- a/tests/test_warmup_offline.py +++ b/tests/test_warmup_offline.py @@ -108,7 +108,7 @@ def test_every_endpoint_method_and_path(self): (lambda c: c.get_statistics_detailed("sender@example.com"), "GET", "/statistics-detailed"), (lambda c: c.download_statistics("sender@example.com", str(out / "stats.csv")), "GET", "/statistics/download"), (lambda c: c.get_from_address_statistics("sender@example.com"), "GET", "/from-address-statistics"), - (lambda c: c.check_domain_dmarc("example.com"), "GET", "/check-domain-dmarc"), + (lambda c: c.check_domain_dmarc("sender@example.com"), "GET", "/check-domain-dmarc"), (lambda c: c.test_smtp_configuration("sender@example.com", "sender@example.com", "pw", "smtp.example.com", 587), "POST", "/test-smtp-configuration"), ] self.assertEqual(len(cases), 26) @@ -190,8 +190,8 @@ def test_request_bodies_and_queries(self): ) self.requests.clear() - self.client.check_domain_dmarc("example.com", dkim_selector="default") - self.assertEqual(self._only().query, {"from_address": "example.com", "dkim_selector": "default"}) + self.client.check_domain_dmarc("sender@example.com", dkim_selector="default") + self.assertEqual(self._only().query, {"from_address": "sender@example.com", "dkim_selector": "default"}) self.requests.clear() self.client.create_custom_engagement("sender@example.com", 20, 25, 25) @@ -226,6 +226,7 @@ def test_invalid_arguments_send_nothing(self): lambda: self.client.get_statistics_per_day("s@example.com", "2026-09-14", "2026-09-01"), lambda: self.client.get_statistics_per_day("s@example.com", "09/01/2026", "2026-09-14"), lambda: self.client.check_domain_dmarc(""), + lambda: self.client.check_domain_dmarc("example.com"), # the live API wants an email address lambda: self.client.test_smtp_configuration("s@example.com", "u", "pw", "smtp.example.com", 0), lambda: self.client.test_smtp_configuration("s@example.com", "u", "", "smtp.example.com", 587), ] diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index c09cc92..cb91a21 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -316,7 +316,7 @@ def test_validate_batch_sends_optional_body_fields(self): verify_plus=True, ) payload = self.requests_mock.post.call_args[1]["json"] - self.assertEqual(payload["timeout"], 60) + self.assertEqual(payload["timeout"], "60") # a string: the live API rejects a JSON number self.assertTrue(payload["activity_data"]) self.assertTrue(payload["verify_plus"]) @@ -800,7 +800,7 @@ def test_add_filter_posts_to_main_api(self): def test_validate_s3file_posts_to_bulk_root(self): self.requests_mock.post.return_value = MockResponse({"success": True, "file_id": "s3-1"}) self.zero_bounce_client.validate_s3file( - "lists/jan.csv", has_header_row=True, remove_duplicates=True + "lists/jan.csv", has_header_row=True, remove_duplicates=True, return_url="https://example.com/cb" ) url = self.requests_mock.post.call_args[0][0] self.assertEqual(url, "https://bulkapi.zerobounce.net/validateS3file")