From 7632fff8011b8a2224df487d91eacd619b3d787d Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:50:35 +0400 Subject: [PATCH 01/18] feat: v2 validate parity (verify_plus, activity_data, timeouts) Add optional validate and validate_batch parameters and activity fields on validation responses per ZeroBounce v2 docs. Co-authored-by: Cursor --- src/zerobouncesdk/zb_validate_response.py | 6 ++ src/zerobouncesdk/zerobouncesdk.py | 68 ++++++++++++++++++++--- tests/zero_bounce_test_case.py | 34 ++++++++++++ 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/src/zerobouncesdk/zb_validate_response.py b/src/zerobouncesdk/zb_validate_response.py index 04a78c7..c8c8789 100644 --- a/src/zerobouncesdk/zb_validate_response.py +++ b/src/zerobouncesdk/zb_validate_response.py @@ -34,6 +34,12 @@ class ZBValidateResponse(ZBResponse): domain_age_days: str = None """Age of the email domain in days or [null].""" + active_in_days: str = None + """Last activity bucket when activity_data was requested (e.g. 30, 365+).""" + + active_first_seen: str = None + """First date activity was detected (YYYY-MM-DD).""" + free_email: bool = False """[true/false] If the email comes from a free provider.""" diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 9bb88a1..888598c 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -232,7 +232,24 @@ def get_activity(self, email: str): f"{self._base_url}/activity", ZBGetActivityResponse, params={"email": email} ) - def validate(self, email: str, ip_address: str = None): + @staticmethod + def _check_validate_timeout(timeout: Optional[int]) -> None: + if timeout is not None and (timeout < 3 or timeout > 60): + raise ZBClientException("Invalid parameter: timeout (must be between 3 and 60 seconds)") + + @staticmethod + def _check_validate_batch_timeout(timeout: Optional[int]) -> None: + if timeout is not None and (timeout < 10 or timeout > 120): + raise ZBClientException("Invalid parameter: timeout (must be between 10 and 120 seconds)") + + def validate( + self, + email: str, + ip_address: str = None, + timeout: Optional[int] = None, + activity_data: Optional[bool] = None, + verify_plus: Optional[bool] = None, + ): """Validates the given email address. Parameters @@ -241,33 +258,61 @@ def validate(self, email: str, ip_address: str = None): The email address you want to validate ip_address: str or None The IP Address the email signed up from (Can be blank) + timeout: int or None + Validation timeout in seconds (3-60). Optional. + activity_data: bool or None + When set, sends ``activity_data`` query parameter. + verify_plus: bool or None + When set, sends ``verify_plus`` query parameter. Raises ------ ZBApiException + ZBClientException Returns ------- response: ZBValidateResponse Returns a ZBValidateResponse object if the request was successful """ + self._check_validate_timeout(timeout) + + params = { + "email": email, + "ip_address": ip_address, + } + if timeout is not None: + params["timeout"] = timeout + if activity_data is not None: + params["activity_data"] = "true" if activity_data else "false" + if verify_plus is not None: + params["verify_plus"] = "true" if verify_plus else "false" return self._get( f"{self._base_url}/validate", ZBValidateResponse, - params={ - "email": email, - "ip_address": ip_address, - }, + params=params, ) - def validate_batch(self, email_batch: List[ZBValidateBatchElement]): + def validate_batch( + self, + email_batch: List[ZBValidateBatchElement], + timeout: Optional[int] = None, + activity_data: Optional[bool] = None, + verify_plus: Optional[bool] = None, + ): """Allows you to send us batches up to 100 emails at a time. Parameters ---------- email_batch: List[ZBValidateBatchElement] Array of ZBValidateBatchElement + timeout: int or None + Batch validation timeout in seconds (10-120). Optional. + activity_data: bool or None + When set, includes activity data on each result. + verify_plus: bool or None + When true, uses Verify+ for validation. Raises ------ @@ -281,17 +326,24 @@ def validate_batch(self, email_batch: List[ZBValidateBatchElement]): """ if not email_batch: raise ZBClientException("Empty parameter: email_batch") + self._check_validate_batch_timeout(timeout) - json={ + payload = { "api_key": self._api_key, "email_batch": [ batch_element.to_json() for batch_element in email_batch ], } + if timeout is not None: + payload["timeout"] = timeout + if activity_data is not None: + payload["activity_data"] = activity_data + if verify_plus is not None: + payload["verify_plus"] = verify_plus return self._post( f"{self._base_url}/validatebatch", ZBValidateBatchResponse, - json=json) + json=payload) def _send_file( self, diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index 47e2db1..9a1a8dd 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -112,6 +112,23 @@ def test_validate_valid(self): self.assertEqual(response.sub_status, ZBValidateSubStatus.mailbox_not_found) self.assertEqual(response.processed_at, datetime(2023, 3, 28, 12, 30, 18, 990000)) + def test_validate_sends_optional_query_params(self): + self.requests_mock.get.return_value = MockResponse({"address": "a@b.com", "status": "valid"}) + self.zero_bounce_client.validate( + "a@b.com", + timeout=10, + activity_data=True, + verify_plus=False, + ) + params = self.requests_mock.get.call_args[1]["params"] + self.assertEqual(params["timeout"], 10) + self.assertEqual(params["activity_data"], "true") + self.assertEqual(params["verify_plus"], "false") + + def test_validate_timeout_out_of_range(self): + with self.assertRaises(ZBClientException): + self.zero_bounce_client.validate("a@b.com", timeout=2) + def test_response_sub_status_accept_all(self): self.requests_mock.get.return_value = MockResponse({ "address": "none@example.com", @@ -200,6 +217,23 @@ def test_validate_batch_valid(self): self.assertEqual(response.email_batch[1].sub_status, ZBValidateSubStatus.mailbox_not_found) self.assertEqual(len(response.errors), 0) + def test_validate_batch_sends_optional_body_fields(self): + self.requests_mock.post.return_value = MockResponse({"email_batch": [], "errors": []}) + self.zero_bounce_client.validate_batch( + [ZBValidateBatchElement("a@b.com")], + timeout=60, + activity_data=True, + verify_plus=True, + ) + payload = self.requests_mock.post.call_args[1]["json"] + self.assertEqual(payload["timeout"], 60) + self.assertTrue(payload["activity_data"]) + self.assertTrue(payload["verify_plus"]) + + def test_validate_batch_timeout_out_of_range(self): + with self.assertRaises(ZBClientException): + self.zero_bounce_client.validate_batch([ZBValidateBatchElement("a@b.com")], timeout=9) + def test_validate_batch_uses_api_base_url(self): self.requests_mock.post.return_value = MockResponse({"email_batch": [], "errors": []}) self.zero_bounce_client.validate_batch([ZBValidateBatchElement("a@b.com")]) From f8c546f7872f9e656ee5fab9d376aee13af7c5a7 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:46:52 +0400 Subject: [PATCH 02/18] feat: add score_email for GET/POST /v2/scoring Co-authored-by: Cursor --- src/zerobouncesdk/__init__.py | 1 + src/zerobouncesdk/zb_score_email_response.py | 8 +++ src/zerobouncesdk/zerobouncesdk.py | 52 ++++++++++++++++++++ tests/zero_bounce_test_case.py | 39 +++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 src/zerobouncesdk/zb_score_email_response.py diff --git a/src/zerobouncesdk/__init__.py b/src/zerobouncesdk/__init__.py index 8c40c56..3e1f11a 100644 --- a/src/zerobouncesdk/__init__.py +++ b/src/zerobouncesdk/__init__.py @@ -6,6 +6,7 @@ from .zb_get_credits_response import ZBGetCreditsResponse from .zb_get_api_usage_response import ZBGetApiUsageResponse from .zb_get_activity_response import ZBGetActivityResponse +from .zb_score_email_response import ZBScoreEmailResponse from .zb_validate_status import ZBValidateStatus from .zb_validate_sub_status import ZBValidateSubStatus from .zb_validate_response import ZBValidateResponse diff --git a/src/zerobouncesdk/zb_score_email_response.py b/src/zerobouncesdk/zb_score_email_response.py new file mode 100644 index 0000000..789649c --- /dev/null +++ b/src/zerobouncesdk/zb_score_email_response.py @@ -0,0 +1,8 @@ +from ._zb_response import ZBResponse + + +class ZBScoreEmailResponse(ZBResponse): + """Response for GET/POST /scoring (real-time AI scoring).""" + + email: str = None + score: int = None diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 888598c..21db73a 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -16,6 +16,7 @@ ZBGetCreditsResponse, ZBGetApiUsageResponse, ZBGetActivityResponse, + ZBScoreEmailResponse, ZBValidateResponse, ZBValidateBatchElement, ZBValidateBatchResponse, @@ -165,6 +166,26 @@ def _post(self, url, response_class, data=None, json=None, files=None): return response_class(json_response) + def _post_form(self, url, response_class, data=None): + if not data: + data = {} + data = dict(data) + data["api_key"] = self._api_key + try: + response = requests.post(url, data=data, timeout=self._timeout_s) + except RequestException as e: + raise type(e)(self._redact(e)) from None + + try: + json_response = response.json() + except ValueError as e: + raise ZBApiException from e + + error = json_response.pop("error", None) + if error: + raise ZBApiException(error) + return response_class(json_response) + def get_credits(self): """Tells you how many credits you have left on your account. It's simple, fast and easy to use. @@ -232,6 +253,37 @@ def get_activity(self, email: str): f"{self._base_url}/activity", ZBGetActivityResponse, params={"email": email} ) + def score_email(self, email: str, use_post: bool = False): + """Real-time AI scoring for a single email (GET or POST /scoring). + + Parameters + ---------- + email: str + The email address to score + use_post: bool + When True, sends ``api_key`` and ``email`` as form-encoded POST body. + + Raises + ------ + ZBApiException + + Returns + ------- + response: ZBScoreEmailResponse + """ + + if use_post: + return self._post_form( + f"{self._base_url}/scoring", + ZBScoreEmailResponse, + data={"email": email}, + ) + return self._get( + f"{self._base_url}/scoring", + ZBScoreEmailResponse, + params={"email": email}, + ) + @staticmethod def _check_validate_timeout(timeout: Optional[int]) -> None: if timeout is not None and (timeout < 3 or timeout > 60): diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index 9a1a8dd..279e295 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -98,6 +98,45 @@ def test_activity_valid(self): self.assertTrue(response.found) self.assertEqual(response.active_in_days, "180") + def test_score_email_get(self): + self.requests_mock.get.return_value = MockResponse({ + "email": "mark@gmail.com", + "score": 10, + }) + + response = self.zero_bounce_client.score_email("mark@gmail.com") + self.assertEqual(response.email, "mark@gmail.com") + self.assertEqual(response.score, 10) + self.requests_mock.get.assert_called_with( + f"{self.zero_bounce_client._base_url}/scoring", + params={"api_key": "dummy_key", "email": "mark@gmail.com"}, + timeout=120.0, + ) + + def test_score_email_post(self): + self.requests_mock.post.return_value = MockResponse({ + "email": "mark@gmail.com", + "score": 10, + }) + + response = self.zero_bounce_client.score_email("mark@gmail.com", use_post=True) + self.assertEqual(response.email, "mark@gmail.com") + self.assertEqual(response.score, 10) + self.requests_mock.post.assert_called_with( + f"{self.zero_bounce_client._base_url}/scoring", + data={"api_key": "dummy_key", "email": "mark@gmail.com"}, + timeout=120.0, + ) + + def test_score_email_api_error(self): + self.requests_mock.get.return_value = MockResponse({ + "error": "Please provide a valid email address", + }) + + with self.assertRaises(ZBApiException) as cm: + self.zero_bounce_client.score_email("not-an-email") + self.assertEqual(str(cm.exception), "Please provide a valid email address") + def test_validate_valid(self): self.requests_mock.get.return_value = MockResponse({ "address": "invalid@example.com", From 317f7d64e0a08d01c4c57f1b38bd47fabd491e81 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:34:21 +0400 Subject: [PATCH 03/18] feat: Priority 3 API parity (bulk finder, filters, S3) Mirror JavaScript P3 surfaces: email-finder and domain-search bulk jobs, list evaluator, allow/block filters, and S3 validate/score/export helpers. Co-authored-by: Cursor --- src/zerobouncesdk/zerobouncesdk.py | 385 +++++++++++++++++++++++++++++ tests/zero_bounce_test_case.py | 39 +++ 2 files changed, 424 insertions(+) diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 21db73a..8f93c0e 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -34,6 +34,9 @@ class ZeroBounce: """The ZeroBounce main class. All the requests are implemented here.""" BULK_BASE_URL = "https://bulkapi.zerobounce.net/v2" + BULK_ROOT_URL = "https://bulkapi.zerobounce.net" + EMAIL_FINDER_BASE_URL = "https://bulkapi.zerobounce.net/email-finder" + DOMAIN_SEARCH_BASE_URL = "https://bulkapi.zerobounce.net/domain-search" SCORING_BASE_URL = "https://bulkapi.zerobounce.net/v2/scoring" DEFAULT_HTTP_TIMEOUT_S = 120.0 @@ -186,6 +189,107 @@ def _post_form(self, url, response_class, data=None): raise ZBApiException(error) return response_class(json_response) + def _request_json(self, method: str, url: str, *, params=None, data=None, files=None): + if params is None: + params = {} + if "api_key" not in params and (data is None or "api_key" not in data): + params = dict(params) + params["api_key"] = self._api_key + try: + response = requests.request( + method, + url, + params=params, + data=data, + files=files, + timeout=self._timeout_s, + ) + except RequestException as e: + raise type(e)(self._redact(e)) from None + try: + return response.json() + except ValueError as e: + raise ZBApiException( + f"Request not processed successfully. Status code {response.status_code}" + ) from e + + def _send_bulk_root_multipart( + self, url: str, file_path: str, fields: dict + ) -> ZBSendFileResponse: + data = dict(fields) + 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(url, ZBSendFileResponse, data=data, files=files) + + def _send_bulk_root_multipart_stream( + self, url: str, file_stream: BinaryIO, file_name: str, fields: dict + ) -> ZBSendFileResponse: + data = dict(fields) + data["api_key"] = self._api_key + files = {"file": (file_name, file_stream, "text/csv")} + return self._post(url, ZBSendFileResponse, data=data, files=files) + + def _bulk_root_file_status(self, base_url: str, file_id: str) -> ZBFileStatusResponse: + if not file_id.strip(): + raise ZBClientException("Empty parameter: file_id") + return self._get( + f"{base_url}/filestatus", + ZBFileStatusResponse, + params={"file_id": file_id}, + ) + + def _bulk_root_get_file( + self, + base_url: str, + file_id: str, + download_path: str, + ) -> ZBGetFileResponse: + if not file_id.strip(): + raise ZBClientException("Empty parameter: file_id") + params = {"api_key": self._api_key, "file_id": file_id} + try: + response = requests.get( + f"{base_url}/getfile", + params=params, + timeout=self._timeout_s, + ) + except RequestException as e: + raise type(e)(self._redact(e)) from None + body_bytes = response.content or b"" + body_str = body_bytes.decode("utf-8", errors="replace") + content_type = response.headers.get("Content-Type") or "" + if response.status_code > 299: + if body_str.lstrip().startswith("{"): + raise ZBApiException(self._redact(self._format_get_file_error_message(body_str))) + raise ZBApiException(self._redact(body_str or f"HTTP {response.status_code}")) + if self._should_treat_get_file_body_as_error(body_str, content_type): + raise ZBApiException(self._redact(self._format_get_file_error_message(body_str))) + dirname = os.path.dirname(download_path) + if dirname: + os.makedirs(dirname, exist_ok=True) + with open(download_path, "wb") as f: + f.write(body_bytes) + return ZBGetFileResponse({"local_file_path": download_path}) + + def _bulk_root_delete_file(self, base_url: str, file_id: str) -> ZBDeleteFileResponse: + if not file_id.strip(): + raise ZBClientException("Empty parameter: file_id") + return self._get( + f"{base_url}/deletefile", + ZBDeleteFileResponse, + params={"file_id": file_id}, + ) + + def _post_bulk_root_form(self, path: str, fields: dict) -> ZBSendFileResponse: + data = dict(fields) + data["api_key"] = self._api_key + return self._post( + f"{self.BULK_ROOT_URL}{path}", + ZBSendFileResponse, + data=data, + ) + def get_credits(self): """Tells you how many credits you have left on your account. It's simple, fast and easy to use. @@ -863,6 +967,287 @@ def scoring_delete_file(self, file_id: str): return self._delete_file(True, file_id) + # Priority 3 — bulk email finder (bulkapi …/email-finder/*) + + def send_email_finder_file( + self, + file_path: str, + domain_column: int, + first_name_column: int = None, + last_name_column: int = None, + middle_name_column: int = None, + full_name_column: int = None, + has_header_row: bool = False, + ) -> ZBSendFileResponse: + if not domain_column: + raise ZBClientException("Empty parameter: domain_column") + fields = {"domain_column": domain_column, "has_header_row": has_header_row} + if first_name_column is not None: + fields["first_name_column"] = first_name_column + if last_name_column is not None: + fields["last_name_column"] = last_name_column + if middle_name_column is not None: + fields["middle_name_column"] = middle_name_column + if full_name_column is not None: + fields["full_name_column"] = full_name_column + return self._send_bulk_root_multipart( + f"{self.EMAIL_FINDER_BASE_URL}/sendfile", file_path, fields + ) + + def send_email_finder_file_stream( + self, + file_stream: BinaryIO, + file_name: str, + domain_column: int, + first_name_column: int = None, + last_name_column: int = None, + middle_name_column: int = None, + full_name_column: int = None, + has_header_row: bool = False, + ) -> ZBSendFileResponse: + if not domain_column: + raise ZBClientException("Empty parameter: domain_column") + fields = {"domain_column": domain_column, "has_header_row": has_header_row} + if first_name_column is not None: + fields["first_name_column"] = first_name_column + if last_name_column is not None: + fields["last_name_column"] = last_name_column + if middle_name_column is not None: + fields["middle_name_column"] = middle_name_column + if full_name_column is not None: + fields["full_name_column"] = full_name_column + return self._send_bulk_root_multipart_stream( + f"{self.EMAIL_FINDER_BASE_URL}/sendfile", + file_stream, + file_name, + fields, + ) + + def email_finder_file_status(self, file_id: str) -> ZBFileStatusResponse: + return self._bulk_root_file_status(self.EMAIL_FINDER_BASE_URL, file_id) + + def email_finder_get_file(self, file_id: str, download_path: str) -> ZBGetFileResponse: + return self._bulk_root_get_file(self.EMAIL_FINDER_BASE_URL, file_id, download_path) + + def email_finder_delete_file(self, file_id: str) -> ZBDeleteFileResponse: + return self._bulk_root_delete_file(self.EMAIL_FINDER_BASE_URL, file_id) + + # Priority 3 — bulk domain search (bulkapi …/domain-search/*) + + def send_domain_search_file( + self, + file_path: str, + domain_column: int, + has_header_row: bool = False, + ) -> ZBSendFileResponse: + if not domain_column: + raise ZBClientException("Empty parameter: domain_column") + return self._send_bulk_root_multipart( + f"{self.DOMAIN_SEARCH_BASE_URL}/sendfile", + file_path, + {"domain_column": domain_column, "has_header_row": has_header_row}, + ) + + def send_domain_search_file_stream( + self, + file_stream: BinaryIO, + file_name: str, + domain_column: int, + has_header_row: bool = False, + ) -> ZBSendFileResponse: + if not domain_column: + raise ZBClientException("Empty parameter: domain_column") + return self._send_bulk_root_multipart_stream( + f"{self.DOMAIN_SEARCH_BASE_URL}/sendfile", + file_stream, + file_name, + {"domain_column": domain_column, "has_header_row": has_header_row}, + ) + + def domain_search_file_status(self, file_id: str) -> ZBFileStatusResponse: + return self._bulk_root_file_status(self.DOMAIN_SEARCH_BASE_URL, file_id) + + def domain_search_get_file(self, file_id: str, download_path: str) -> ZBGetFileResponse: + return self._bulk_root_get_file(self.DOMAIN_SEARCH_BASE_URL, file_id, download_path) + + def domain_search_delete_file(self, file_id: str) -> ZBDeleteFileResponse: + return self._bulk_root_delete_file(self.DOMAIN_SEARCH_BASE_URL, file_id) + + # Priority 3 — list evaluator + + def send_list_evaluator_file( + self, file_path: str, email_address_column: int + ) -> ZBSendFileResponse: + if not email_address_column: + raise ZBClientException("Empty parameter: email_address_column") + data = { + "api_key": self._api_key, + "email_address_column": email_address_column, + } + 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}/listevaluator/", + ZBSendFileResponse, + data=data, + files=files, + ) + + def send_list_evaluator_file_stream( + self, + file_stream: BinaryIO, + file_name: str, + email_address_column: int, + ) -> ZBSendFileResponse: + if not email_address_column: + raise ZBClientException("Empty parameter: email_address_column") + data = { + "api_key": self._api_key, + "email_address_column": email_address_column, + } + files = {"file": (file_name, file_stream, "text/csv")} + return self._post( + f"{self.BULK_BASE_URL}/listevaluator/", + ZBSendFileResponse, + data=data, + files=files, + ) + + def get_list_evaluator_result(self, file_id: str): + if not file_id.strip(): + raise ZBClientException("Empty parameter: file_id") + return self._request_json( + "GET", + f"{self.BULK_BASE_URL}/listevaluator/{file_id}/", + params={"api_key": self._api_key}, + ) + + 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, + ) + + # Priority 3 — custom filters (main API) + + def add_filter(self, rule: str, target: str, value: str): + if not rule or not target or not value: + raise ZBClientException("Empty parameter: rule, target, and value are required") + return self._request_json( + "POST", + f"{self._base_url}/filters/add", + data={ + "api_key": self._api_key, + "rule": rule, + "target": target, + "value": value, + }, + ) + + def list_filters(self, use_post: bool = False): + if use_post: + return self._request_json( + "POST", + f"{self._base_url}/filters/list", + data={"api_key": self._api_key}, + ) + return self._request_json( + "GET", + f"{self._base_url}/filters/list", + params={"api_key": self._api_key}, + ) + + def delete_filter(self, rule: str, target: str, value: str): + if not rule or not target or not value: + raise ZBClientException("Empty parameter: rule, target, and value are required") + return self._request_json( + "POST", + f"{self._base_url}/filters/delete", + data={ + "api_key": self._api_key, + "rule": rule, + "target": target, + "value": value, + }, + ) + + # Priority 3 — S3 bulk helpers (bulkapi root) + + def validate_s3file( + self, + file_name: str, + has_header_row: bool = None, + remove_duplicates: bool = None, + remove_duplicate: bool = None, + email_address_column: int = None, + return_url: str = None, + first_name_column: int = None, + last_name_column: int = None, + gender_column: int = None, + ip_address_column: int = None, + allow_phase_2: Optional[bool] = None, + ) -> ZBSendFileResponse: + if not file_name or not file_name.strip(): + raise ZBClientException("Empty parameter: file_name") + fields = {"file_name": file_name} + if has_header_row is not None: + fields["has_header_row"] = has_header_row + if remove_duplicates is not None: + fields["remove_duplicates"] = remove_duplicates + if remove_duplicate is not None: + fields["remove_duplicate"] = remove_duplicate + if email_address_column is not None: + fields["email_address_column"] = email_address_column + if return_url is not None: + fields["return_url"] = return_url + if first_name_column is not None: + fields["first_name_column"] = first_name_column + if last_name_column is not None: + fields["last_name_column"] = last_name_column + if gender_column is not None: + fields["gender_column"] = gender_column + if ip_address_column is not None: + fields["ip_address_column"] = ip_address_column + if allow_phase_2 is not None: + fields["allow_phase_2"] = "true" if allow_phase_2 else "false" + return self._post_bulk_root_form("/validateS3file", fields) + + def score_s3file( + self, + file_name: str, + has_header_row: bool = None, + remove_duplicates: bool = None, + remove_duplicate: bool = None, + email_address_column: int = None, + ) -> ZBSendFileResponse: + if not file_name or not file_name.strip(): + raise ZBClientException("Empty parameter: file_name") + fields = {"file_name": file_name} + if has_header_row is not None: + fields["has_header_row"] = has_header_row + if remove_duplicates is not None: + fields["remove_duplicates"] = remove_duplicates + if remove_duplicate is not None: + fields["remove_duplicate"] = remove_duplicate + if email_address_column is not None: + fields["email_address_column"] = email_address_column + return self._post_bulk_root_form("/scoreS3file", fields) + + def export_s3file(self, file_id: str, export_bucket: str) -> ZBSendFileResponse: + if not file_id.strip(): + raise ZBClientException("Empty parameter: file_id") + if not export_bucket or not export_bucket.strip(): + raise ZBClientException("Empty parameter: export_bucket") + return self._post_bulk_root_form( + "/exportS3file", + {"file_id": file_id, "export_bucket": export_bucket}, + ) + def guess_format( self, domain: str, diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index 279e295..0df11f8 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -1,4 +1,5 @@ from datetime import date, datetime, timedelta +from io import BytesIO from pathlib import Path @@ -718,3 +719,41 @@ def test_posts_pass_timeout(self): response = client._post("https://example.com", ZBResponse) self.assertEqual(response.a, 'b') self.requests_mock.post.assert_called_with('https://example.com', data=None, json=None, files=None, timeout=0.123) + + def test_send_email_finder_file_uses_bulk_email_finder_url(self): + self.requests_mock.post.return_value = MockResponse({ + "success": True, + "file_id": "ef-1", + }) + stream = BytesIO(b"domain,name\nacme.com,Jane") + self.zero_bounce_client.send_email_finder_file_stream( + stream, "finder.csv", domain_column=1 + ) + url = self.requests_mock.post.call_args[0][0] + self.assertEqual(url, "https://bulkapi.zerobounce.net/email-finder/sendfile") + + def test_add_filter_posts_to_main_api(self): + self.requests_mock.request.return_value = MockResponse( + {"Message": "Filter successfully added"} + ) + self.zero_bounce_client.add_filter("allow", "domain", "example.com") + method, url = self.requests_mock.request.call_args[0][:2] + self.assertEqual(method, "POST") + self.assertTrue(url.endswith("/filters/add")) + data = self.requests_mock.request.call_args[1]["data"] + self.assertEqual(data["rule"], "allow") + + 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 + ) + url = self.requests_mock.post.call_args[0][0] + self.assertEqual(url, "https://bulkapi.zerobounce.net/validateS3file") + + def test_get_list_evaluator_result_url(self): + self.requests_mock.request.return_value = MockResponse({"file_status": "Complete"}) + self.zero_bounce_client.get_list_evaluator_result("abc-123") + method, url = self.requests_mock.request.call_args[0][:2] + self.assertEqual(method, "GET") + self.assertIn("/v2/listevaluator/abc-123/", url) From d27ab4acadf0ee4c241bb951982e60d297dfc3a3 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:17:25 +0400 Subject: [PATCH 04/18] feat: POST /validate transport parity and listS3files Add form-encoded POST option on single validate (alongside GET) and GET listS3files on bulkapi root, with unit tests and API parity doc updates. --- src/zerobouncesdk/zerobouncesdk.py | 31 ++++++++++++++++++++++++++++++ tests/zero_bounce_test_case.py | 17 ++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 8f93c0e..0e15d16 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -405,6 +405,7 @@ def validate( timeout: Optional[int] = None, activity_data: Optional[bool] = None, verify_plus: Optional[bool] = None, + use_post: bool = False, ): """Validates the given email address. @@ -420,6 +421,8 @@ def validate( When set, sends ``activity_data`` query parameter. verify_plus: bool or None When set, sends ``verify_plus`` query parameter. + use_post: bool + When True, sends parameters as form-encoded POST body. Raises ------ @@ -444,6 +447,12 @@ def validate( if verify_plus is not None: params["verify_plus"] = "true" if verify_plus else "false" + if use_post: + return self._post_form( + f"{self._base_url}/validate", + ZBValidateResponse, + data=params, + ) return self._get( f"{self._base_url}/validate", ZBValidateResponse, @@ -1178,6 +1187,28 @@ def delete_filter(self, rule: str, target: str, value: str): # Priority 3 — S3 bulk helpers (bulkapi root) + def list_s3files( + self, + bucket: str = None, + search: str = None, + ): + """List S3 bucket files available for validation or scoring (GET /listS3files).""" + params = {} + if bucket is not None: + params["bucket"] = bucket + if search is not None: + params["search"] = search + payload = self._request_json( + "GET", + f"{self.BULK_ROOT_URL}/listS3files", + params=params, + ) + if isinstance(payload, dict) and payload.get("error"): + raise ZBApiException(payload["error"]) + if not isinstance(payload, list): + raise ZBApiException("Unexpected listS3files response") + return payload + def validate_s3file( self, file_name: str, diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index 0df11f8..0868557 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -165,6 +165,23 @@ def test_validate_sends_optional_query_params(self): self.assertEqual(params["activity_data"], "true") self.assertEqual(params["verify_plus"], "false") + def test_validate_post_uses_form_body(self): + self.requests_mock.post.return_value = MockResponse({"address": "a@b.com", "status": "valid"}) + self.zero_bounce_client.validate("a@b.com", use_post=True, timeout=30) + self.requests_mock.post.assert_called_once() + data = self.requests_mock.post.call_args[1]["data"] + self.assertEqual(data["email"], "a@b.com") + self.assertEqual(data["timeout"], 30) + + def test_list_s3files_gets_bulk_root(self): + self.requests_mock.get.return_value = MockResponse( + [{"name": "uploads/a.csv", "size": 10, "last_modified": "2024-01-01T00:00:00Z"}] + ) + result = self.zero_bounce_client.list_s3files(bucket="my-bucket", search="a") + self.assertEqual(result[0]["name"], "uploads/a.csv") + called_url = self.requests_mock.get.call_args[0][0] + self.assertIn("bulkapi.zerobounce.net/listS3files", called_url) + def test_validate_timeout_out_of_range(self): with self.assertRaises(ZBClientException): self.zero_bounce_client.validate("a@b.com", timeout=2) From 4b36baa4d5beb3e2ca22ce7d021c75f0f94772a0 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:59:37 +0400 Subject: [PATCH 05/18] fix: mock requests.request in listS3files test Co-authored-by: Cursor --- tests/zero_bounce_test_case.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index 0868557..e40a3d1 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -174,12 +174,12 @@ def test_validate_post_uses_form_body(self): self.assertEqual(data["timeout"], 30) def test_list_s3files_gets_bulk_root(self): - self.requests_mock.get.return_value = MockResponse( + self.requests_mock.request.return_value = MockResponse( [{"name": "uploads/a.csv", "size": 10, "last_modified": "2024-01-01T00:00:00Z"}] ) result = self.zero_bounce_client.list_s3files(bucket="my-bucket", search="a") self.assertEqual(result[0]["name"], "uploads/a.csv") - called_url = self.requests_mock.get.call_args[0][0] + called_url = self.requests_mock.request.call_args[0][1] self.assertIn("bulkapi.zerobounce.net/listS3files", called_url) def test_validate_timeout_out_of_range(self): From 2000394c9035aa43d698adbd92942d035d81e551 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:29:04 +0400 Subject: [PATCH 06/18] fix: return short API errors and encode form booleans HTML access-denied pages and parser exceptions were reaching callers, form flags were sent as True/False, and column 0 was rejected as empty. Co-authored-by: Cursor --- .github/workflows/publish.yml | 5 +- .github/workflows/sdk_ci.yml | 5 +- AGENTS.md | 57 ++ CHANGELOG.md | 140 ++++ pyproject.toml | 2 +- .../zb_find_email_format_response.py | 5 +- src/zerobouncesdk/zerobouncesdk.py | 283 +++++--- tests/test_offline_api.py | 621 ++++++++++++++++++ tests/zero_bounce_test_case.py | 21 +- 9 files changed, 1052 insertions(+), 87 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 tests/test_offline_api.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 56343dc..286c8c0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -86,9 +86,12 @@ jobs: - name: Install dependencies run: pip install -e ".[test]" + - name: Compile + run: python -m compileall -q src tests + - name: Run tests run: | - python -m pytest tests/zero_bounce_test_case.py -v \ + python -m pytest \ --cov=zerobouncesdk --cov-report=term-missing --cov-fail-under=50 publish: diff --git a/.github/workflows/sdk_ci.yml b/.github/workflows/sdk_ci.yml index e4d06ba..f0b2ae7 100644 --- a/.github/workflows/sdk_ci.yml +++ b/.github/workflows/sdk_ci.yml @@ -27,7 +27,10 @@ jobs: - name: Install dependencies run: pip install -e ".[test]" + - name: Compile + run: python -m compileall -q src tests + - name: Run unit tests with coverage run: | - python -m pytest tests/zero_bounce_test_case.py -v \ + python -m pytest \ --cov=zerobouncesdk --cov-report=term-missing --cov-fail-under=50 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..847282d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project + +`zero-bounce-python-sdk-setup` is the ZeroBounce Python SDK, published to PyPI as `zerobouncesdk`. Callers construct a `ZeroBounce` with an API key and call methods that return response objects or raise `ZBApiException` / `ZBClientException`. HTTP goes through the `requests` library inside `src/zerobouncesdk/zerobouncesdk.py`. + +## Layout + +- `src/zerobouncesdk/zerobouncesdk.py`: the client. `_get`, `_post`, `_post_form`, and `_request_json` are the HTTP entry points. +- `src/zerobouncesdk/zb_*.py`: response models, enums, and options. +- `tests/zero_bounce_test_case.py`: scripted unit tests. They patch `zerobouncesdk.zerobouncesdk.requests`. +- `tests/test_offline_api.py`: offline suite. It patches `requests.sessions.Session.send`, so the real `requests` client builds the URL and body and the fake answers without the network. +- `tests/zero_bounce_integration_test.py`: live API tests. `pytest` does not collect this file. Run it with `python -m unittest tests.zero_bounce_integration_test` when `ZEROBOUNCE_API_KEY` is set. +- `pyproject.toml`: the only version field, `version = "x.y.z"`. `setup.py` calls `setup()` and reads that file. + +## Commands + +```bash +pip install -e ".[test]" +python -m compileall -q src tests # syntax check; there is no separate typechecker or linter +python -m pytest # unit tests and tests/test_offline_api.py +``` + +CI (`.github/workflows/sdk_ci.yml`) compiles, then runs `python -m pytest` with coverage. There is no MyPy or Ruff config. Do not add one that fails the existing tree unless asked. + +## Conventions + +- **One `ZeroBounce` per API key.** The class is not a singleton. Do not reuse one instance across users or requests. The offline concurrency test constructs a separate client per key. +- **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 (`first_name_column`, and the S3 email 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. +- **Optional parameters are omitted when unset.** `timeout`, `activity_data`, `verify_plus`, 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`) keep JSON booleans. +- **`allow_phase_2` is validation `send_file` and `validate_s3file` only.** Scoring `send_file` and `score_s3file` do not send it. `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. +- **Do not follow redirects.** Every `requests` call passes `allow_redirects=False`. A 3xx response is a `ZBApiException`. A redirect must not be fetched, because the `Location` could be another host and the next request can carry the API key. +- **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. + +## Changelog + +`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). + +- Add every user-visible change to `## [Unreleased]` in the same commit as the change. +- Group entries under these headings, in this order, omitting empty ones: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. +- Write for SDK users: name the method, option, or behaviour, and say what changed. Leave out CI-only changes, internal refactors, and fixes to code that was never released. +- At 2.y.z, a breaking change bumps the major version, a new method or option bumps the minor version, and fixes alone bump the patch version. + +## Releasing + +Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. + +1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links at the bottom of `CHANGELOG.md`. +2. Set the same version in `pyproject.toml` (`version = "x.y.z"`). The publish workflow fails if it differs from the tag. That is the only version field. There is no runtime `__version__` and no user-agent constant. `setup.py` does not contain a version. +3. `python -m compileall -q src tests` and `python -m pytest`. +4. Tag `vx.y.z` on that commit and run the Publish workflow (`.github/workflows/publish.yml`) with that tag. It publishes `zerobouncesdk` to PyPI. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e89d96a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,140 @@ +# 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 + +- `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`. +- `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`). +- Filters (`add_filter`, `list_filters`, `delete_filter`). +- S3 bulk (`validate_s3file`, `score_s3file`, `export_s3file`, `list_s3files`). + +### Changed + +- A custom `base_url` must be an `https://` URL. +- API requests time out after 120 seconds unless the caller passes `timeout`. +- API requests do not follow redirects. + +### Fixed + +- A 403 HTML "Access denied" page (about 1.4 MB from the live API on `get_file`) is raised as a short `ZBApiException`. An HTML page with status 200 is an error as well, including `get_file`, `email_finder_get_file`, and `domain_search_get_file`, instead of a saved CSV. +- Empty and non-JSON bodies are a short `ZBApiException`. The message is no longer a chained `JSONDecodeError`, and a doubled `Error: Error:` / `[Error]: [Error]:` prefix is collapsed to one. +- `validate_batch` raises `ZBApiException` 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. +- `send_file`, `scoring_send_file`, bulk email finder, domain search, and S3 uploads send `has_header_row`, `remove_duplicate`, and `remove_duplicates` as `true` or `false`. `has_header_row=False` is sent. +- `send_email_finder_file`, `send_domain_search_file`, and `send_list_evaluator_file` send column `0`. They previously treated `0` as empty and rejected it before the request. +- `find_email_format` raises `ZBApiException` when the API returns a `Message` error, the same way `find_domain` and `guess_format` already did. + +### Security + +- Error messages no longer include the raw HTML access-denied page. The live `get_file` page is about 1.4 MB and contains the API key. +- Redirects are not followed, so a response cannot send the client, or the API key, to another host. + +## [2.2.3] - 2026-06-18 + +### Fixed + +- `validate_batch` posts to `api.zerobounce.net/v2/validatebatch` instead of `bulkapi.zerobounce.net`. + +## [2.2.2] - 2026-06-10 + +### Added + +- `ZBValidateResponse.catchall_domain`, read from `catchall_domain`. + +## [2.2.0] - 2026-04-05 + +### Added + +- `send_file` / `send_file_stream` `allow_phase_2`, sent as `allow_phase_2` on validation bulk sendfile only. +- `ZBGetFileOptions` for `get_file` and `scoring_get_file`, with `download_type` (`phase_1`, `phase_2`, `combined`) and `activity_data` (validation `get_file` only). +- `ZBFileStatusResponse.file_phase_2_status`. + +### Fixed + +- `get_file` raises `ZBApiException` when the body is a JSON error, including HTTP 200 with `"success": false`, instead of saving that body as the results file. + +## [2.1.10] - 2026-03-03 + +### Changed + +- License the project under MIT. + +## [2.1.0] - 2026-03-03 + +### Added + +- `ZeroBounce(..., timeout=timedelta)` to set the HTTP timeout. +- `send_file_stream` and `scoring_send_file_stream`. + +### Fixed + +- `get_credits` and `validate_batch` tolerate a response that omits `Credits`, `email_batch`, or `errors`. + +## [2.0.1] - 2026-01-26 + +### Fixed + +- `ZBValidateSubStatus.gold` and unknown status values are recognized again. The 2.0.0 tag did not include them. + +## [2.0.0] - 2026-01-26 + +### Removed + +- `ZBValidateSubStatus.gold` and the fallback that kept an unknown status from raising. Restored in 2.0.1. + +## [1.1.3] - 2026-01-26 + +### Added + +- `find_email_format` and `find_domain`. +- `ZeroBounce(api_key, base_url)` with `ZBApiUrl` (`API_DEFAULT_URL`, `API_USA_URL`, `API_EU_URL`) or an https URL string. +- `ZBValidateSubStatus.gold` and `ZBValidateSubStatus.role_based_accept_all`. + +### Changed + +- An unknown status, sub-status, or confidence value warns and becomes `None` instead of raising. + +### Deprecated + +- `guess_format`. Use `find_email_format` or `find_domain`. + +## [1.1.2] - 2025-08-07 + +### Added + +- `ZBValidateSubStatus.accept_all`. + +## [1.1.1] - 2024-12-20 + +### Fixed + +- API error payloads raise `ZBApiException` with the error text. + +## [1.1.0] - 2023-09-12 + +### Added + +- Initial release: `get_credits`, `get_api_usage`, `get_activity`, `validate`, `validate_batch`, bulk and AI-scoring file methods, and `guess_format`. + +[Unreleased]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v2.2.4...HEAD +[2.2.3]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v2.2.2...v2.2.3 +[2.2.2]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v2.2.0...v2.2.2 +[2.2.0]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v2.1.10...v2.2.0 +[2.1.10]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v2.1.0...v2.1.10 +[2.1.0]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v2.0.1...v2.1.0 +[2.0.1]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v2.0.0...v2.0.1 +[2.0.0]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v1.1.3...v2.0.0 +[1.1.3]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v1.1.2...v1.1.3 +[1.1.2]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v1.1.1...v1.1.2 +[1.1.1]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/compare/v1.1.0...v1.1.1 +[1.1.0]: https://github.com/zerobounce/zero-bounce-python-sdk-setup/releases/tag/v1.1.0 diff --git a/pyproject.toml b/pyproject.toml index fbb7a4b..27594a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ Homepage = "https://github.com/zerobounce/zero-bounce-python-sdk-setup" [tool.pytest.ini_options] testpaths = ["tests"] -python_files = ["test_*.py", "*_test.py", "zero_bounce_test_case.py"] +python_files = ["test_*.py", "zero_bounce_test_case.py"] python_functions = ["test_*"] addopts = "-v --tb=short" filterwarnings = ["ignore::DeprecationWarning"] diff --git a/src/zerobouncesdk/zb_find_email_format_response.py b/src/zerobouncesdk/zb_find_email_format_response.py index 7108b3c..811262d 100644 --- a/src/zerobouncesdk/zb_find_email_format_response.py +++ b/src/zerobouncesdk/zb_find_email_format_response.py @@ -1,4 +1,4 @@ -from . import ZBConfidence +from . import ZBApiException, ZBConfidence from ._zb_response import ZBResponse @@ -25,6 +25,9 @@ class ZBFindEmailFormatResponse(ZBResponse): def __init__(self, data): super().__init__(data) + if "Message" in data or "message" in data: + message = data.get("message", data.get("Message")) + raise ZBApiException(message) if self.email_confidence and isinstance(self.email_confidence, str): self.email_confidence = ZBConfidence(self.email_confidence.lower()) diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 0e15d16..c0283d0 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -1,4 +1,5 @@ import json +import re import warnings from datetime import date, timedelta import os @@ -30,6 +31,120 @@ ) +def _looks_like_html(body: str, content_type: str = "") -> bool: + if content_type and "text/html" in content_type.lower(): + return True + sample = (body or "").lstrip()[:2000].lower() + return ( + " str: + snippet = body[:8000] + lower = snippet.lower() + start = lower.find("", start) + if open_end < 0: + return "" + close = lower.find("", open_end) + if close < 0: + return "" + return snippet[open_end + 1:close].strip() + + +def _strip_error_prefix(message: str) -> str: + """Collapse a repeated ``Error:`` / ``[Error]:`` prefix and leave a single one.""" + stripped = message.strip() + changed = True + while changed: + changed = False + match = re.match(r"(?i)(?:error:\s*){2,}", stripped) + if match: + stripped = ("Error: " + stripped[match.end():]).strip() + changed = True + match = re.match(r"(?i)(?:\[error\]:\s*){2,}", stripped) + if match: + stripped = ("[Error]: " + stripped[match.end():]).strip() + changed = True + return stripped + + +def _message_from_body(body: str, status_code: int) -> str: + """Turn an HTTP body into a short message. Never return raw HTML or a parser exception.""" + trimmed = (body or "").strip() + if not trimmed: + return "Empty response from ZeroBounce (HTTP %s)" % status_code + if _looks_like_html(body): + title = _html_title(body) + if title and "access denied" in title.lower(): + return "Access denied" + if title: + return 'ZeroBounce returned an HTML error page ("%s") instead of an API response.' % title + return "ZeroBounce returned an HTML error page instead of an API response." + if trimmed[0] in "{[": + try: + parsed = json.loads(trimmed) + except ValueError: + return "ZeroBounce returned a response that is not valid JSON." + if isinstance(parsed, dict): + title = parsed.get("title") + if isinstance(title, str) and ("Access denied" in title or "1020" in title): + has_api_message = False + for key in ("error", "message", "error_message"): + value = parsed.get(key) + if isinstance(value, str) and value.strip(): + has_api_message = True + elif isinstance(value, list) and value: + has_api_message = True + if parsed.get("cloudflare_error") is True or not has_api_message: + return title + for key in ("message", "error", "error_message"): + value = parsed.get(key) + if isinstance(value, str) and value.strip(): + return _strip_error_prefix(value) + if isinstance(value, list) and value and isinstance(value[0], str) and value[0].strip(): + return _strip_error_prefix(value[0]) + if len(trimmed) > 500: + return "ZeroBounce returned an error (HTTP %s)." % status_code + return trimmed + if len(trimmed) > 1000 or any(char in trimmed for char in "{}[]<>"): + return "ZeroBounce returned a response that is not valid JSON." + message = _strip_error_prefix(trimmed) + if not message: + return "Empty response from ZeroBounce (HTTP %s)" % status_code + return message + + +def _form_data(data): + """Encode booleans as the strings ``true`` and ``false`` for form and multipart bodies. + + ``requests`` would otherwise send a Python bool as ``True`` or ``False``. + """ + if not isinstance(data, dict): + return data + encoded = {} + for key, value in data.items(): + if isinstance(value, bool): + encoded[key] = "true" if value else "false" + else: + encoded[key] = value + return encoded + + +def _response_text(response) -> str: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray)): + return content.decode("utf-8", errors="replace") + text = getattr(response, "text", "") + return text if isinstance(text, str) else "" + + class ZeroBounce: """The ZeroBounce main class. All the requests are implemented here.""" @@ -109,24 +224,6 @@ def get_file_json_indicates_error(body: str) -> bool: return True return "success" in o - @staticmethod - def _format_get_file_error_message(body: str) -> str: - try: - o = json.loads(body) - except ValueError: - return body if body else "Invalid getfile response" - if not isinstance(o, dict): - return body - for k in ("message", "error", "error_message"): - v = o.get(k) - if not v: - continue - if isinstance(v, str) and v.strip(): - return v.strip() - if isinstance(v, list) and v and isinstance(v[0], str): - return v[0].strip() - return body - @staticmethod def _should_treat_get_file_body_as_error(body: str, content_type: str) -> bool: ct = content_type.lower() @@ -141,33 +238,84 @@ def _redact(self, text) -> str: text = text.replace(secret, "***") return text + def _read_json_payload(self, response): + body = _response_text(response) + status = getattr(response, "status_code", 0) or 0 + headers = getattr(response, "headers", None) or {} + 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.") + ) + if status > 299 or _looks_like_html(body, content_type) or not body.strip(): + raise ZBApiException(self._redact(_message_from_body(body, status))) + try: + payload = response.json() + except ValueError: + raise ZBApiException(self._redact(_message_from_body(body, status))) from None + if isinstance(payload, dict): + title = payload.get("title") + if isinstance(title, str) and payload.get("cloudflare_error") is True: + raise ZBApiException(self._redact(title)) + error = None + for key in ("error", "Error"): + if key in payload and payload[key]: + error = payload[key] + break + if isinstance(error, str) and error.strip(): + raise ZBApiException(self._redact(_strip_error_prefix(error))) + if error: + raise ZBApiException(self._redact(str(error))) + success = payload.get("success") + failed = success is False or ( + isinstance(success, str) and success.lower() == "false" + ) + if failed: + message = payload.get("message") or payload.get("error_message") or "Request failed" + if isinstance(message, list): + message = message[0] if message else "Request failed" + text = str(message).strip() or "Request failed" + raise ZBApiException(self._redact(_strip_error_prefix(text))) + return payload + + def _raise_if_download_error(self, response, body_str: str, content_type: str) -> None: + status = response.status_code + if 300 <= status < 400: + raise ZBApiException( + self._redact("ZeroBounce returned a redirect, which this client does not follow.") + ) + if ( + status > 299 + or _looks_like_html(body_str, content_type) + or self._should_treat_get_file_body_as_error(body_str, content_type) + or not body_str.strip() + or "text/plain" in content_type.lower() + ): + raise ZBApiException(self._redact(_message_from_body(body_str, status))) + def _get(self, url, response_class, params=None): if not params: params = {} params["api_key"] = self._api_key try: - response = requests.get(url, params=params, timeout=self._timeout_s) + response = requests.get(url, params=params, timeout=self._timeout_s, allow_redirects=False) except RequestException as e: raise type(e)(self._redact(e)) from None - - try: - json_response = response.json() - except ValueError as e: - raise ZBApiException from e - - error = json_response.pop("error", None) - if error: - raise ZBApiException(error) - return response_class(json_response) + return response_class(self._read_json_payload(response)) def _post(self, url, response_class, data=None, json=None, files=None): - response = requests.post(url, data=data, json=json, files=files, timeout=self._timeout_s) try: - json_response = response.json() - except ValueError as e: - raise ZBApiException('Request not processed succesfully. Status code %s' % response.status_code) - - return response_class(json_response) + response = requests.post( + url, + data=_form_data(data), + json=json, + files=files, + timeout=self._timeout_s, + allow_redirects=False, + ) + except RequestException as e: + raise type(e)(self._redact(e)) from None + return response_class(self._read_json_payload(response)) def _post_form(self, url, response_class, data=None): if not data: @@ -175,19 +323,12 @@ def _post_form(self, url, response_class, data=None): data = dict(data) data["api_key"] = self._api_key try: - response = requests.post(url, data=data, timeout=self._timeout_s) + response = requests.post( + url, data=_form_data(data), timeout=self._timeout_s, allow_redirects=False + ) except RequestException as e: raise type(e)(self._redact(e)) from None - - try: - json_response = response.json() - except ValueError as e: - raise ZBApiException from e - - error = json_response.pop("error", None) - if error: - raise ZBApiException(error) - return response_class(json_response) + return response_class(self._read_json_payload(response)) def _request_json(self, method: str, url: str, *, params=None, data=None, files=None): if params is None: @@ -200,18 +341,14 @@ def _request_json(self, method: str, url: str, *, params=None, data=None, files= method, url, params=params, - data=data, + data=_form_data(data), files=files, timeout=self._timeout_s, + allow_redirects=False, ) except RequestException as e: raise type(e)(self._redact(e)) from None - try: - return response.json() - except ValueError as e: - raise ZBApiException( - f"Request not processed successfully. Status code {response.status_code}" - ) from e + return self._read_json_payload(response) def _send_bulk_root_multipart( self, url: str, file_path: str, fields: dict @@ -253,18 +390,14 @@ def _bulk_root_get_file( f"{base_url}/getfile", params=params, timeout=self._timeout_s, + allow_redirects=False, ) except RequestException as e: raise type(e)(self._redact(e)) from None body_bytes = response.content or b"" body_str = body_bytes.decode("utf-8", errors="replace") content_type = response.headers.get("Content-Type") or "" - if response.status_code > 299: - if body_str.lstrip().startswith("{"): - raise ZBApiException(self._redact(self._format_get_file_error_message(body_str))) - raise ZBApiException(self._redact(body_str or f"HTTP {response.status_code}")) - if self._should_treat_get_file_body_as_error(body_str, content_type): - raise ZBApiException(self._redact(self._format_get_file_error_message(body_str))) + self._raise_if_download_error(response, body_str, content_type) dirname = os.path.dirname(download_path) if dirname: os.makedirs(dirname, exist_ok=True) @@ -505,10 +638,18 @@ def validate_batch( payload["activity_data"] = activity_data if verify_plus is not None: payload["verify_plus"] = verify_plus - return self._post( + result = self._post( f"{self._base_url}/validatebatch", ZBValidateBatchResponse, json=payload) + for batch_error in result.errors or []: + address = (batch_error.email_address or "").strip().lower() + if address == "all": + text = batch_error.error or "Invalid API Key or your account ran out of credits" + if isinstance(text, str): + text = _strip_error_prefix(text) + raise ZBApiException(text) + return result def _send_file( self, @@ -840,20 +981,14 @@ def _get_file( f"{self.SCORING_BASE_URL if scoring else self.BULK_BASE_URL}/getfile", params=params, timeout=self._timeout_s, + allow_redirects=False, ) except RequestException as e: raise type(e)(self._redact(e)) from None body_bytes = response.content or b"" body_str = body_bytes.decode("utf-8", errors="replace") content_type = response.headers.get("Content-Type") or "" - - if response.status_code > 299: - if body_str.lstrip().startswith("{"): - raise ZBApiException(self._redact(self._format_get_file_error_message(body_str))) - raise ZBApiException(self._redact(body_str or f"HTTP {response.status_code}")) - - if self._should_treat_get_file_body_as_error(body_str, content_type): - raise ZBApiException(self._redact(self._format_get_file_error_message(body_str))) + self._raise_if_download_error(response, body_str, content_type) dirname = os.path.dirname(download_path) if dirname: @@ -988,7 +1123,7 @@ def send_email_finder_file( full_name_column: int = None, has_header_row: bool = False, ) -> ZBSendFileResponse: - if not domain_column: + if domain_column is None: raise ZBClientException("Empty parameter: domain_column") fields = {"domain_column": domain_column, "has_header_row": has_header_row} if first_name_column is not None: @@ -1014,7 +1149,7 @@ def send_email_finder_file_stream( full_name_column: int = None, has_header_row: bool = False, ) -> ZBSendFileResponse: - if not domain_column: + if domain_column is None: raise ZBClientException("Empty parameter: domain_column") fields = {"domain_column": domain_column, "has_header_row": has_header_row} if first_name_column is not None: @@ -1049,7 +1184,7 @@ def send_domain_search_file( domain_column: int, has_header_row: bool = False, ) -> ZBSendFileResponse: - if not domain_column: + if domain_column is None: raise ZBClientException("Empty parameter: domain_column") return self._send_bulk_root_multipart( f"{self.DOMAIN_SEARCH_BASE_URL}/sendfile", @@ -1064,7 +1199,7 @@ def send_domain_search_file_stream( domain_column: int, has_header_row: bool = False, ) -> ZBSendFileResponse: - if not domain_column: + if domain_column is None: raise ZBClientException("Empty parameter: domain_column") return self._send_bulk_root_multipart_stream( f"{self.DOMAIN_SEARCH_BASE_URL}/sendfile", @@ -1087,7 +1222,7 @@ def domain_search_delete_file(self, file_id: str) -> ZBDeleteFileResponse: def send_list_evaluator_file( self, file_path: str, email_address_column: int ) -> ZBSendFileResponse: - if not email_address_column: + if email_address_column is None: raise ZBClientException("Empty parameter: email_address_column") data = { "api_key": self._api_key, @@ -1108,7 +1243,7 @@ def send_list_evaluator_file_stream( file_name: str, email_address_column: int, ) -> ZBSendFileResponse: - if not email_address_column: + if email_address_column is None: raise ZBClientException("Empty parameter: email_address_column") data = { "api_key": self._api_key, diff --git a/tests/test_offline_api.py b/tests/test_offline_api.py new file mode 100644 index 0000000..bb8283b --- /dev/null +++ b/tests/test_offline_api.py @@ -0,0 +1,621 @@ +"""Offline API suite. + +A fake ``requests`` transport records every call and answers without the network. +Error modes are selected by the API key prefix and follow the live API. +""" + +import json +import random +import tempfile +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import date +from email.parser import BytesParser +from email.policy import HTTP +from io import BytesIO +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +import requests +from requests import Response + +from zerobouncesdk import ( + ZBApiException, + ZBClientException, + ZBGetFileOptions, + ZBValidateBatchElement, + ZeroBounce, +) + +_HTML_PAGE = ( + "Access denied | zerobounce.net" + + ("Access denied. " * 4000) + + "" +) + + +class RecordedRequest: + def __init__(self, method, url, body, headers): + parsed = urlparse(url) + self.method = method.upper() + self.url = url + self.host = parsed.hostname + self.path = parsed.path + self.query = {k: v[-1] for k, v in parse_qs(parsed.query, keep_blank_values=True).items()} + self.headers = {k.lower(): v for k, v in (headers or {}).items()} + self.body = body if isinstance(body, (bytes, bytearray)) else (body or "").encode() + content_type = self.headers.get("content-type", "") + self.json = None + self.form = {} + self.files = {} + if "application/json" in content_type and self.body: + self.json = json.loads(self.body.decode()) + elif "multipart/form-data" in content_type and self.body: + self.form, self.files = _parse_multipart(content_type, self.body) + elif self.body and "application/x-www-form-urlencoded" in content_type: + self.form = { + k: v[-1] + for k, v in parse_qs(self.body.decode(), keep_blank_values=True).items() + } + elif self.body and self.body[:1] in (b"{", b"["): + self.json = json.loads(self.body.decode()) + + @property + def api_key(self): + if "api_key" in self.query: + return self.query["api_key"] + if self.json and isinstance(self.json, dict) and "api_key" in self.json: + return self.json["api_key"] + return self.form.get("api_key") + + def field(self, name): + if name in self.query: + return self.query[name] + if self.json and isinstance(self.json, dict) and name in self.json: + return self.json[name] + if name in self.form: + return self.form[name] + return None + + def has_field(self, name): + if name in self.query: + return True + if self.json and isinstance(self.json, dict) and name in self.json: + return True + return name in self.form + + +def _parse_multipart(content_type, body): + raw = b"Content-Type: " + content_type.encode() + b"\r\n\r\n" + body + message = BytesParser(policy=HTTP).parsebytes(raw) + form = {} + files = {} + for part in message.iter_parts(): + name = part.get_param("name", header="content-disposition") + filename = part.get_filename() + payload = part.get_payload(decode=True) or b"" + if filename: + files[name] = {"filename": filename, "data": payload} + else: + form[name] = payload.decode() + return form, files + + +class FakeTransport: + def __init__(self): + self.requests = [] + self._lock = threading.Lock() + + def send(self, session, request, **kwargs): + time.sleep(random.uniform(0.005, 0.02)) + recorded = RecordedRequest(request.method, request.url, request.body, request.headers) + with self._lock: + self.requests.append(recorded) + return self._reply(recorded) + + def _reply(self, recorded): + key = recorded.api_key or "" + if key.startswith("redirect-"): + return _response(302, b"", "text/plain", headers={"Location": "https://evil.example/steal"}) + if key.startswith("denied-"): + return _response(403, _HTML_PAGE.encode(), "text/html; charset=UTF-8") + if key.startswith("html200-"): + page = "Not a filenot csv" + return _response(200, page.encode(), "text/html") + if key.startswith("empty-"): + return _response(200, b"", "text/plain") + if key.startswith("malformed-"): + return _response(200, b"this is not json {{{", "application/json") + if key.startswith("invalid-") or key.startswith("nocredits-"): + return self._account_error(recorded, key) + if key.startswith("itemerr-") and recorded.path.endswith("/validatebatch"): + payload = { + "email_batch": [{"address": "good@example.com", "status": "valid"}], + "errors": [{"email_address": "bad@example.com", "error": "Invalid email address"}], + } + return _response(200, json.dumps(payload).encode(), "application/json") + if key.startswith("doubled-"): + return _response( + 200, + json.dumps({"error": "Error: Error: Invalid API key"}).encode(), + "application/json", + ) + return _response(200, json.dumps(self._success(recorded, key)).encode(), "application/json") + + def _account_error(self, recorded, key): + no_credits = key.startswith("nocredits-") + text = "Your account ran out of credits" if no_credits else "Invalid API key or your account ran out of credits" + path = recorded.path + if path.endswith("/getcredits"): + return _response(200, b'{"Credits":"-1"}', "application/json") + if path.endswith("/getapiusage") and not no_credits: + return _response(200, b'{"error":"Invalid API key"}', "application/json") + if path.endswith("/validatebatch"): + payload = { + "email_batch": [], + "errors": [{"email_address": "all", "error": "Invalid API Key or your account ran out of credits"}], + } + return _response(200, json.dumps(payload).encode(), "application/json") + if path.endswith("/getfile"): + return _response(403, _HTML_PAGE.encode(), "text/html; charset=UTF-8") + if path.endswith("/deletefile") or path.endswith("/filestatus") and "email-finder" in path or path.endswith("/filestatus") and "domain-search" in path: + return _response(403, _HTML_PAGE.encode(), "text/html; charset=UTF-8") + if path.endswith("/filestatus"): + payload = {"success": "False", "message": ["api_key is invalid"]} + return _response(401, json.dumps(payload).encode(), "application/json") + if path.endswith("/sendfile") or path.rstrip("/").endswith("listevaluator") or "send-evaluated-file" in path: + payload = {"success": "False", "message": ["api_key is invalid"]} + return _response(401, json.dumps(payload).encode(), "application/json") + if path.endswith("/listS3files") or "S3file" in path or path.rstrip("/").endswith("listevaluator") or "/listevaluator/" in path: + payload = {"success": "False", "message": ["api_key is invalid"]} + status = 401 + return _response(status, json.dumps(payload).encode(), "application/json") + if "/filters/" in path: + payload = {"Error": text} + return _response(200, json.dumps(payload).encode(), "application/json") + if path.endswith("/guessformat"): + return _response(200, json.dumps({"Message": text}).encode(), "application/json") + return _response(200, json.dumps({"error": text}).encode(), "application/json") + + def _success(self, recorded, key): + path = recorded.path + echo = {"echo_key": key} + if path.endswith("/getfile"): + # Replaced below with CSV bytes. Marker keeps the branch obvious. + return {"__csv__": key} + if path.endswith("/getcredits"): + return {"Credits": key} + if path.endswith("/getapiusage"): + return {"total": 1, "start_date": "1/1/2024", "end_date": "1/2/2024", **echo} + if path.endswith("/activity"): + return {"found": True, "active_in_days": "30", **echo} + if path.endswith("/scoring"): + return {"email": recorded.field("email"), "score": 9, **echo} + if path.endswith("/validate"): + return {"address": recorded.field("email"), "status": "valid", **echo} + if path.endswith("/validatebatch"): + return { + "email_batch": [{"address": "a@b.com", "status": "valid", "echo_key": key}], + "errors": [], + } + if path.endswith("/filestatus"): + return { + "success": True, + "file_id": recorded.field("file_id"), + "file_name": "emails.csv", + "upload_date": "2023-03-28T12:30:18Z", + "file_status": "Complete", + "complete_percentage": "100%", + **echo, + } + if path.endswith("/deletefile"): + return {"success": True, "message": "File Deleted", "file_id": recorded.field("file_id"), "file_name": "emails.csv", **echo} + if path.endswith("/sendfile") or "listevaluator" in path or "send-evaluated-file" in path or path.endswith("S3file"): + return {"success": True, "message": "File Accepted", "file_id": "file-1", "file_name": "emails.csv", **echo} + if path.endswith("/guessformat"): + return { + "email": "ada@example.com", + "domain": recorded.field("domain") or "example.com", + "company_name": recorded.field("company_name") or "", + "format": "first", + "status": "valid", + "sub_status": "", + "confidence": "high", + "email_confidence": "high", + "did_you_mean": "", + "failure_reason": "", + "other_domain_formats": [], + **echo, + } + if "/filters/" in path: + return {"Message": "Filter successfully added", **echo} + if path.endswith("/listS3files"): + return [{"name": "uploads/a.csv", "echo_key": key}] + if "/listevaluator/" in path: + return {"file_status": "Complete", **echo} + return {"ok": True, **echo} + + def __call__(self, request, **kwargs): + reply = self.send(None, request, **kwargs) + # getfile success is CSV, not the JSON marker. + if reply.headers.get("Content-Type", "").startswith("application/json"): + try: + payload = json.loads(reply.content.decode()) + except ValueError: + return reply + if isinstance(payload, dict) and "__csv__" in payload: + key = payload["__csv__"] + return _response(200, f"email\n{key}\n".encode(), "text/csv") + return reply + + +def _response(status, body, content_type, headers=None): + response = Response() + response.status_code = status + response._content = body + response.headers["Content-Type"] = content_type + response.encoding = "utf-8" + response.url = "https://api.zerobounce.net/v2/unused" + if headers: + for name, value in headers.items(): + response.headers[name] = value + return response + + +class OfflineApiTest(TestCase): + def setUp(self): + self.transport = FakeTransport() + + def send(session, request, **kwargs): + return self.transport(request, **kwargs) + + self._patch = patch("requests.sessions.Session.send", send) + self._patch.start() + self.addCleanup(self._patch.stop) + self.client = ZeroBounce("client-key") + + def _only(self): + self.assertEqual(len(self.transport.requests), 1) + return self.transport.requests[0] + + def _csv(self, name="rows.csv"): + path = Path(tempfile.mkdtemp()) / name + path.write_text("email,name\nada@example.com,Ada\n") + return path + + def test_every_public_method_hits_the_right_endpoint(self): + csv = self._csv() + stream = BytesIO(b"email\nada@example.com\n") + out = Path(tempfile.mkdtemp()) / "out.csv" + cases = [ + ("get_credits", lambda: self.client.get_credits(), "GET", "api.zerobounce.net", "/v2/getcredits"), + ("get_api_usage", lambda: self.client.get_api_usage(date(2024, 1, 2), date(2024, 1, 3)), "GET", "api.zerobounce.net", "/v2/getapiusage"), + ("get_activity", lambda: self.client.get_activity("ada@example.com"), "GET", "api.zerobounce.net", "/v2/activity"), + ("score_email", lambda: self.client.score_email("ada@example.com"), "GET", "api.zerobounce.net", "/v2/scoring"), + ("score_email_post", lambda: self.client.score_email("ada@example.com", use_post=True), "POST", "api.zerobounce.net", "/v2/scoring"), + ("validate", lambda: self.client.validate("ada@example.com"), "GET", "api.zerobounce.net", "/v2/validate"), + ("validate_post", lambda: self.client.validate("ada@example.com", use_post=True), "POST", "api.zerobounce.net", "/v2/validate"), + ("validate_batch", lambda: self.client.validate_batch([ZBValidateBatchElement("ada@example.com", "1.2.3.4")]), "POST", "api.zerobounce.net", "/v2/validatebatch"), + ("send_file", lambda: self.client.send_file(str(csv), 1), "POST", "bulkapi.zerobounce.net", "/v2/sendfile"), + ("send_file_stream", lambda: self.client.send_file_stream(BytesIO(b"a@b.com\n"), "a.csv", 1), "POST", "bulkapi.zerobounce.net", "/v2/sendfile"), + ("scoring_send_file", lambda: self.client.scoring_send_file(str(csv), 1), "POST", "bulkapi.zerobounce.net", "/v2/scoring/sendfile"), + ("scoring_send_file_stream", lambda: self.client.scoring_send_file_stream(stream, "a.csv", 1), "POST", "bulkapi.zerobounce.net", "/v2/scoring/sendfile"), + ("file_status", lambda: self.client.file_status("file-9"), "GET", "bulkapi.zerobounce.net", "/v2/filestatus"), + ("scoring_file_status", lambda: self.client.scoring_file_status("file-9"), "GET", "bulkapi.zerobounce.net", "/v2/scoring/filestatus"), + ("get_file", lambda: self.client.get_file("file-9", str(out)), "GET", "bulkapi.zerobounce.net", "/v2/getfile"), + ("scoring_get_file", lambda: self.client.scoring_get_file("file-9", str(out) + ".score"), "GET", "bulkapi.zerobounce.net", "/v2/scoring/getfile"), + ("delete_file", lambda: self.client.delete_file("file-9"), "GET", "bulkapi.zerobounce.net", "/v2/deletefile"), + ("scoring_delete_file", lambda: self.client.scoring_delete_file("file-9"), "GET", "bulkapi.zerobounce.net", "/v2/scoring/deletefile"), + ("send_email_finder_file", lambda: self.client.send_email_finder_file(str(csv), 1), "POST", "bulkapi.zerobounce.net", "/email-finder/sendfile"), + ("send_email_finder_file_stream", lambda: self.client.send_email_finder_file_stream(BytesIO(b"example.com\n"), "f.csv", 1), "POST", "bulkapi.zerobounce.net", "/email-finder/sendfile"), + ("email_finder_file_status", lambda: self.client.email_finder_file_status("file-9"), "GET", "bulkapi.zerobounce.net", "/email-finder/filestatus"), + ("email_finder_get_file", lambda: self.client.email_finder_get_file("file-9", str(out) + ".finder"), "GET", "bulkapi.zerobounce.net", "/email-finder/getfile"), + ("email_finder_delete_file", lambda: self.client.email_finder_delete_file("file-9"), "GET", "bulkapi.zerobounce.net", "/email-finder/deletefile"), + ("send_domain_search_file", lambda: self.client.send_domain_search_file(str(csv), 1), "POST", "bulkapi.zerobounce.net", "/domain-search/sendfile"), + ("send_domain_search_file_stream", lambda: self.client.send_domain_search_file_stream(BytesIO(b"example.com\n"), "d.csv", 1), "POST", "bulkapi.zerobounce.net", "/domain-search/sendfile"), + ("domain_search_file_status", lambda: self.client.domain_search_file_status("file-9"), "GET", "bulkapi.zerobounce.net", "/domain-search/filestatus"), + ("domain_search_get_file", lambda: self.client.domain_search_get_file("file-9", str(out) + ".domain"), "GET", "bulkapi.zerobounce.net", "/domain-search/getfile"), + ("domain_search_delete_file", lambda: self.client.domain_search_delete_file("file-9"), "GET", "bulkapi.zerobounce.net", "/domain-search/deletefile"), + ("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/"), + ("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"), "POST", "bulkapi.zerobounce.net", "/validateS3file"), + ("score_s3file", lambda: self.client.score_s3file("lists/jan.csv"), "POST", "bulkapi.zerobounce.net", "/scoreS3file"), + ("export_s3file", lambda: self.client.export_s3file("file-9", "export-bucket"), "POST", "bulkapi.zerobounce.net", "/exportS3file"), + ("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"), + ("find_domain", lambda: self.client.find_domain(domain="example.com"), "GET", "api.zerobounce.net", "/v2/guessformat"), + ] + for name, call, method, host, path in cases: + self.transport.requests.clear() + call() + recorded = self._only() + self.assertEqual(recorded.method, method, name) + self.assertEqual(recorded.host, host, name) + self.assertEqual(recorded.path, path, name) + self.assertEqual(recorded.api_key, "client-key", name) + + def test_validate_batch_posts_json_to_the_api_host(self): + self.client.validate_batch( + [ZBValidateBatchElement("ada@example.com", "0.0.0.0")], + timeout=30, + activity_data=False, + verify_plus=True, + ) + recorded = self._only() + self.assertNotIn("bulkapi", recorded.host) + 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) + self.assertIs(recorded.json["activity_data"], False) + self.assertIs(recorded.json["verify_plus"], True) + + def test_optional_parameters_are_omitted_until_set(self): + self.client.validate("ada@example.com") + recorded = self._only() + self.assertEqual(recorded.query["email"], "ada@example.com") + for name in ("ip_address", "timeout", "activity_data", "verify_plus"): + self.assertNotIn(name, recorded.query) + self.transport.requests.clear() + self.client.validate( + "ada@example.com", + ip_address="203.0.113.8", + timeout=10, + activity_data=False, + verify_plus=False, + ) + recorded = self._only() + self.assertEqual(recorded.query["ip_address"], "203.0.113.8") + self.assertEqual(recorded.query["timeout"], "10") + self.assertEqual(recorded.query["activity_data"], "false") + self.assertEqual(recorded.query["verify_plus"], "false") + + def test_query_values_are_percent_encoded(self): + client = ZeroBounce("key with space&plus") + client.validate("a b@ex.com") + recorded = self._only() + self.assertNotIn(" ", recorded.url) + self.assertIn("key+with+space%26plus", recorded.url) + self.assertIn("a+b%40ex.com", recorded.url) + self.assertEqual(recorded.api_key, "key with space&plus") + self.assertEqual(recorded.query["email"], "a b@ex.com") + + def test_column_zero_is_sent_and_unset_columns_are_left_out(self): + csv = self._csv() + self.client.send_file(str(csv), 0, first_name_column=0, has_header_row=False, remove_duplicate=False) + recorded = self._only() + self.assertEqual(recorded.form["email_address_column"], "0") + self.assertEqual(recorded.form["first_name_column"], "0") + self.assertNotIn("last_name_column", recorded.form) + self.assertNotIn("gender_column", recorded.form) + self.assertNotIn("ip_address_column", recorded.form) + self.assertNotIn("allow_phase_2", recorded.form) + self.assertEqual(recorded.form["has_header_row"], "false") + self.assertEqual(recorded.form["remove_duplicate"], "false") + + self.transport.requests.clear() + self.client.send_email_finder_file(str(csv), 0, has_header_row=False) + recorded = self._only() + self.assertEqual(recorded.form["domain_column"], "0") + self.assertEqual(recorded.form["has_header_row"], "false") + self.assertNotIn("first_name_column", recorded.form) + + self.transport.requests.clear() + self.client.send_domain_search_file(str(csv), 0, has_header_row=False) + self.assertEqual(self._only().form["has_header_row"], "false") + + self.transport.requests.clear() + self.client.send_list_evaluator_file(str(csv), 0) + self.assertEqual(self._only().form["email_address_column"], "0") + + self.transport.requests.clear() + self.client.validate_s3file("a.csv", email_address_column=0, has_header_row=False, remove_duplicate=False, allow_phase_2=False) + recorded = self._only() + self.assertEqual(recorded.form["email_address_column"], "0") + self.assertEqual(recorded.form["has_header_row"], "false") + self.assertEqual(recorded.form["remove_duplicate"], "false") + self.assertEqual(recorded.form["allow_phase_2"], "false") + self.assertNotIn("remove_duplicates", recorded.form) + + self.transport.requests.clear() + self.client.scoring_send_file(str(csv), 1, has_header_row=True, remove_duplicate=True) + recorded = self._only() + self.assertEqual(recorded.form["has_header_row"], "true") + self.assertEqual(recorded.form["remove_duplicate"], "true") + self.assertNotIn("allow_phase_2", recorded.form) + + def test_get_file_options_and_scoring_omits_activity_data(self): + out = Path(tempfile.mkdtemp()) / "out.csv" + self.client.get_file("file-9", str(out), ZBGetFileOptions(download_type="phase_1", activity_data=False)) + recorded = self._only() + self.assertEqual(recorded.query["download_type"], "phase_1") + self.assertEqual(recorded.query["activity_data"], "false") + self.assertIn("client-key", out.read_text()) + + self.transport.requests.clear() + self.client.scoring_get_file("file-9", str(out) + ".s", ZBGetFileOptions(download_type="combined", activity_data=True)) + recorded = self._only() + self.assertEqual(recorded.query["download_type"], "combined") + self.assertNotIn("activity_data", recorded.query) + + def test_invalid_arguments_send_no_request(self): + csv = self._csv() + attempts = [ + lambda: ZeroBounce(" "), + lambda: ZeroBounce("k", base_url="http://evil.example/v2"), + lambda: self.client.validate("a@b.com", timeout=2), + lambda: self.client.validate("a@b.com", timeout=61), + lambda: self.client.validate_batch([]), + lambda: self.client.validate_batch([ZBValidateBatchElement("a@b.com")], timeout=9), + lambda: ZBValidateBatchElement(" "), + lambda: self.client.file_status(" "), + lambda: self.client.get_file(" ", "out.csv"), + lambda: self.client.delete_file(" "), + lambda: self.client.find_email_format("Ada"), + lambda: self.client.find_email_format("Ada", domain="example.com", company_name="Acme"), + lambda: self.client.find_domain(), + 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(" "), + lambda: self.client.export_s3file(" ", "bucket"), + lambda: self.client.export_s3file("file-9", " "), + lambda: self.client.get_list_evaluator_result(" "), + lambda: self.client.email_finder_file_status(" "), + ] + for attempt in attempts: + self.transport.requests.clear() + with self.assertRaises((ZBClientException, ZBApiException)): + attempt() + self.assertEqual(self.transport.requests, [], attempt) + + def test_account_errors_raise_a_short_message(self): + csv = self._csv() + out = Path(tempfile.mkdtemp()) / "out.csv" + client = ZeroBounce("invalid-key") + credits = client.get_credits() + self.assertEqual(credits.credits, "-1") + + raised = [ + lambda: client.get_api_usage(date(2024, 1, 1), date(2024, 1, 2)), + lambda: client.get_activity("a@b.com"), + lambda: client.score_email("a@b.com"), + lambda: client.score_email("a@b.com", use_post=True), + lambda: client.validate("a@b.com"), + lambda: client.validate("a@b.com", use_post=True), + lambda: client.validate_batch([ZBValidateBatchElement("a@b.com")]), + lambda: client.file_status("file-9"), + lambda: client.get_file("file-9", str(out)), + lambda: client.delete_file("file-9"), + lambda: client.send_file(str(csv), 1), + lambda: client.list_filters(), + lambda: client.add_filter("block", "domain", "x.com"), + lambda: client.list_s3files(), + lambda: client.find_email_format("Ada", domain="example.com"), + lambda: client.find_domain(domain="example.com"), + lambda: client.guess_format("example.com"), + lambda: client.get_list_evaluator_result("file-9"), + lambda: client.email_finder_file_status("file-9"), + lambda: client.email_finder_get_file("file-9", str(out) + ".f"), + lambda: client.domain_search_get_file("file-9", str(out) + ".d"), + lambda: ZeroBounce("nocredits-key").validate("a@b.com"), + ] + for call in raised: + with self.assertRaises(ZBApiException) as caught: + call() + message = str(caught.exception) + self.assertGreater(len(message), 0, call) + self.assertLess(len(message), 300, call) + lowered = message.lower() + self.assertNotIn(" Date: Mon, 19 Oct 2026 06:35:19 +0400 Subject: [PATCH 07/18] fix: save plain-text file downloads and lint the SDK get_file treated every text/plain body as an error, so a CSV with that content type never saved. Empty downloads still raise. CI runs a narrow Ruff check with the unit tests. Co-authored-by: Cursor --- .github/workflows/publish.yml | 7 +++++-- .github/workflows/sdk_ci.yml | 7 +++++-- AGENTS.md | 13 +++++++++---- pyproject.toml | 3 +++ src/zerobouncesdk/zerobouncesdk.py | 4 ++-- tests/__main__.py | 1 + tests/test_offline_api.py | 1 - 7 files changed, 25 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 286c8c0..7ac54ae 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -86,8 +86,11 @@ jobs: - name: Install dependencies run: pip install -e ".[test]" - - name: Compile - run: python -m compileall -q src tests + - name: Lint + run: | + pip install ruff + ruff check src tests + python -m compileall -q src tests - name: Run tests run: | diff --git a/.github/workflows/sdk_ci.yml b/.github/workflows/sdk_ci.yml index f0b2ae7..7d4ec91 100644 --- a/.github/workflows/sdk_ci.yml +++ b/.github/workflows/sdk_ci.yml @@ -27,8 +27,11 @@ jobs: - name: Install dependencies run: pip install -e ".[test]" - - name: Compile - run: python -m compileall -q src tests + - name: Lint + run: | + pip install ruff + ruff check src tests + python -m compileall -q src tests - name: Run unit tests with coverage run: | diff --git a/AGENTS.md b/AGENTS.md index 847282d..5f0546b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,11 +19,15 @@ Guidance for AI coding agents working in this repository. ```bash pip install -e ".[test]" -python -m compileall -q src tests # syntax check; there is no separate typechecker or linter +pip install ruff +ruff check src tests # undefined names and syntax errors only +python -m compileall -q src tests python -m pytest # unit tests and tests/test_offline_api.py ``` -CI (`.github/workflows/sdk_ci.yml`) compiles, then runs `python -m pytest` with coverage. There is no MyPy or Ruff config. Do not add one that fails the existing tree unless asked. +`ruff check` plus `compileall` is the lint step. There is no typechecker. The Ruff selection in `pyproject.toml` is `E9`, `F63`, `F7`, and `F82`. Do not widen it to a style ruleset that fails the existing tree. + +CI (`.github/workflows/sdk_ci.yml`) runs `ruff check`, `compileall`, then `python -m pytest` with coverage. ## Conventions @@ -33,7 +37,8 @@ CI (`.github/workflows/sdk_ci.yml`) compiles, then runs `python -m pytest` with - **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`) keep JSON booleans. - **`allow_phase_2` is validation `send_file` and `validate_s3file` only.** Scoring `send_file` and `score_s3file` do not send it. `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. +- **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. +- **A file download is not JSON.** `get_file` saves a non-empty CSV body, including `text/csv` and `text/plain`. An empty body, an HTML page (status 200 or 403), and a JSON error are `ZBApiException`. Do not treat every `text/plain` body as an error. - **Do not follow redirects.** Every `requests` call passes `allow_redirects=False`. A 3xx response is a `ZBApiException`. A redirect must not be fetched, because the `Location` could be another host and the next request can carry the API key. - **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. @@ -53,5 +58,5 @@ Publishing and pushing tags need a maintainer's explicit approval. Do not bump t 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 `pyproject.toml` (`version = "x.y.z"`). The publish workflow fails if it differs from the tag. That is the only version field. There is no runtime `__version__` and no user-agent constant. `setup.py` does not contain a version. -3. `python -m compileall -q src tests` and `python -m pytest`. +3. `ruff check src tests`, `python -m compileall -q src tests`, and `python -m pytest`. 4. Tag `vx.y.z` on that commit and run the Publish workflow (`.github/workflows/publish.yml`) with that tag. It publishes `zerobouncesdk` to PyPI. diff --git a/pyproject.toml b/pyproject.toml index 27594a6..f5bc8a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ test = ["pytest>=7.0.0", "pytest-cov>=4.0.0"] [project.urls] Homepage = "https://github.com/zerobounce/zero-bounce-python-sdk-setup" +[tool.ruff.lint] +select = ["E9", "F63", "F7", "F82"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py", "zero_bounce_test_case.py"] diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index c0283d0..caad7b4 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -284,12 +284,12 @@ def _raise_if_download_error(self, response, body_str: str, content_type: str) - raise ZBApiException( self._redact("ZeroBounce returned a redirect, which this client does not follow.") ) + if not (body_str or "").strip(): + raise ZBApiException(self._redact(_message_from_body(body_str, 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) - or not body_str.strip() - or "text/plain" in content_type.lower() ): raise ZBApiException(self._redact(_message_from_body(body_str, status))) diff --git a/tests/__main__.py b/tests/__main__.py index 8196143..72e7a10 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -1,5 +1,6 @@ import unittest +from .test_offline_api import OfflineApiTest from .zero_bounce_test_case import ZeroBounceTestCase from .zero_bounce_integration_test import ZeroBounceIntegrationTestCase diff --git a/tests/test_offline_api.py b/tests/test_offline_api.py index bb8283b..9154bd6 100644 --- a/tests/test_offline_api.py +++ b/tests/test_offline_api.py @@ -19,7 +19,6 @@ from unittest.mock import patch from urllib.parse import parse_qs, urlparse -import requests from requests import Response from zerobouncesdk import ( From 407f0a8066f150d0ec4ab91627fa72ea728e2ae3 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:51:02 +0400 Subject: [PATCH 08/18] feat: validate WHOIS and greylist options, Inbox Placement Tester, list evaluator base URL - validate takes domain_info, greylist_processing and callback_url; ZBValidateResponse reads domain_website_exists, domain_registrant_company_name and the greylist "still in progress" message. - Inbox Placement Tester: providers, test types, create/get/list/delete tests, get/set webhook on bulkapi.zerobounce.net/api/ipt (INBOX_PLACEMENT_BASE_URL). - List evaluator requests use LIST_EVALUATOR_BASE_URL, so they can be pointed at a host whose path differs from the other bulk endpoints; file_id is URL-encoded in the result path. - Error bodies with a "detail" field (Django REST framework) raise with that text. - README and documentation.md show the new and earlier parity methods. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + README.md | 114 +++++++++++++ documentation.md | 114 +++++++++++++ src/zerobouncesdk/zb_validate_response.py | 11 ++ src/zerobouncesdk/zerobouncesdk.py | 197 +++++++++++++++++++++- tests/test_offline_api.py | 139 +++++++++++++++ 6 files changed, 572 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e89d96a..d0594be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - List evaluator (`send_list_evaluator_file`, `get_list_evaluator_result`, `send_evaluated_file`). - Filters (`add_filter`, `list_filters`, `delete_filter`). - S3 bulk (`validate_s3file`, `score_s3file`, `export_s3file`, `list_s3files`). +- `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. +- `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 diff --git a/README.md b/README.md index 9dece25..c4734f8 100644 --- a/README.md +++ b/README.md @@ -393,6 +393,120 @@ except ZBException as e: print("ZeroBounce delete_file error: " + str(e)) ``` +### Validation options + +* ##### Validate with activity data, Verify+, WHOIS and greylist retry +```python +from zerobouncesdk import ZeroBounce, ZBException + +zero_bounce = ZeroBounce("") + +try: + response = zero_bounce.validate( + "valid@example.com", + timeout=30, # 3-60 seconds; unknown/greylisted when it runs out + activity_data=True, # adds active_in_days and active_first_seen + verify_plus=True, # overrides the account's Verify+ setting + domain_info=True, # adds domain_website_exists and domain_registrant_company_name + greylist_processing=True, # process a greylisted address again instead of returning unknown + ) + if response.message: # greylist retry still running: call validate again in about 30 minutes + print(response.message) + else: + print(response.status, response.domain_website_exists, response.domain_registrant_company_name) +except ZBException as e: + print("ZeroBounce validate error: " + str(e)) +``` + +With `greylist_processing=True`, pass `callback_url="https://..."` to have the result sent there instead of picking it up. Every option is omitted from the request until you set it, and `use_post=True` sends them as a form-encoded `POST`. + +* ##### Score one email in real time +```python +response = zero_bounce.score_email("valid@example.com") # GET /v2/scoring +print(response.email, response.score) +``` + +### Bulk email finder and domain search + +```python +from zerobouncesdk import ZeroBounce, ZBException + +zero_bounce = ZeroBounce("") + +try: + # Columns are 1-based. Email finder needs a domain column and a first-name or full-name column. + sent = zero_bounce.send_email_finder_file( + "./people.csv", domain_column=3, first_name_column=1, last_name_column=2, has_header_row=True + ) + print(zero_bounce.email_finder_file_status(sent.file_id)) + zero_bounce.email_finder_get_file(sent.file_id, "./people_results.csv") + zero_bounce.email_finder_delete_file(sent.file_id) # only when the status is Complete + + sent = zero_bounce.send_domain_search_file("./domains.csv", domain_column=1, has_header_row=True) + print(zero_bounce.domain_search_file_status(sent.file_id)) +except ZBException as e: + print("ZeroBounce bulk finder error: " + str(e)) +``` + +### List evaluator + +```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) # dict: status, progress, invalid_percentage, ... +zero_bounce.send_evaluated_file("./list.csv") # submit the evaluated list for validation +``` + +### Allowlist and blocklist filters + +```python +zero_bounce.add_filter("block", "domain", "example.com") # rule: allow | block; target: email | domain | mx | tld +print(zero_bounce.list_filters()) # [{"rule": ..., "target": ..., "value": ...}] +zero_bounce.delete_filter("block", "domain", "example.com") +``` + +### Inbox Placement Tester + +Tests use the account's inbox-test credits. + +```python +from zerobouncesdk import ZeroBounce, ZBException + +zero_bounce = ZeroBounce("") + +try: + providers = zero_bounce.get_inbox_placement_providers() + test = zero_bounce.create_inbox_placement_test( + "Spring launch", # unique, at most 64 characters + test_type=3, # 1 Email (needs sender_email), 2 Unique ID, 3 Auto Alias + webhook_url="https://example.com/ipt-webhook", + ) + print(test["emailAddressList"]) # send the campaign to these seed addresses + + result = zero_bounce.get_inbox_placement_test(test["id"]) + print(result["status"], result.get("percentages")) + + finished = zero_bounce.list_inbox_placement_tests(status="finished", page_size=25) + zero_bounce.delete_inbox_placement_test(test["id"]) # finished tests only + zero_bounce.set_inbox_placement_webhook("https://example.com/ipt-webhook") + print(zero_bounce.get_inbox_placement_webhook()) +except ZBException as e: + print("ZeroBounce inbox placement error: " + str(e)) +``` + +### Other hosts + +`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: + +```python +zero_bounce = ZeroBounce("", base_url="https://api-eu.zerobounce.net/v2") +zero_bounce.BULK_BASE_URL = "https://bulkapi.zerobounce.net/v2" # send/status/get/delete file +zero_bounce.SCORING_BASE_URL = "https://bulkapi.zerobounce.net/v2/scoring" +zero_bounce.EMAIL_FINDER_BASE_URL = "https://bulkapi.zerobounce.net/email-finder" +zero_bounce.DOMAIN_SEARCH_BASE_URL = "https://bulkapi.zerobounce.net/domain-search" +zero_bounce.LIST_EVALUATOR_BASE_URL = "https://bulkapi.zerobounce.net/v2/listevaluator" +zero_bounce.INBOX_PLACEMENT_BASE_URL = "https://bulkapi.zerobounce.net/api/ipt" +``` + * ##### (Deprecated) Identify the correct email format when you provide a name and email domain > **⚠️ Deprecated:** The `guess_format` method is deprecated and will be removed in future versions. Use `find_email_format` or `find_domain` instead (see examples above). diff --git a/documentation.md b/documentation.md index baeda14..12ab32c 100644 --- a/documentation.md +++ b/documentation.md @@ -377,3 +377,117 @@ try: except ZBException as e: print("ZeroBounce delete_file error: " + str(e)) ``` + +##### Validation options + +* ####### Validate with activity data, Verify+, WHOIS and greylist retry +```python +from zerobouncesdk import ZeroBounce, ZBException + +zero_bounce = ZeroBounce("") + +try: + response = zero_bounce.validate( + "valid@example.com", + timeout=30, # 3-60 seconds; unknown/greylisted when it runs out + activity_data=True, # adds active_in_days and active_first_seen + verify_plus=True, # overrides the account's Verify+ setting + domain_info=True, # adds domain_website_exists and domain_registrant_company_name + greylist_processing=True, # process a greylisted address again instead of returning unknown + ) + if response.message: # greylist retry still running: call validate again in about 30 minutes + print(response.message) + else: + print(response.status, response.domain_website_exists, response.domain_registrant_company_name) +except ZBException as e: + print("ZeroBounce validate error: " + str(e)) +``` + +With `greylist_processing=True`, pass `callback_url="https://..."` to have the result sent there instead of picking it up. Every option is omitted from the request until you set it, and `use_post=True` sends them as a form-encoded `POST`. + +* ####### Score one email in real time +```python +response = zero_bounce.score_email("valid@example.com") # GET /v2/scoring +print(response.email, response.score) +``` + +##### Bulk email finder and domain search + +```python +from zerobouncesdk import ZeroBounce, ZBException + +zero_bounce = ZeroBounce("") + +try: + # Columns are 1-based. Email finder needs a domain column and a first-name or full-name column. + sent = zero_bounce.send_email_finder_file( + "./people.csv", domain_column=3, first_name_column=1, last_name_column=2, has_header_row=True + ) + print(zero_bounce.email_finder_file_status(sent.file_id)) + zero_bounce.email_finder_get_file(sent.file_id, "./people_results.csv") + zero_bounce.email_finder_delete_file(sent.file_id) # only when the status is Complete + + sent = zero_bounce.send_domain_search_file("./domains.csv", domain_column=1, has_header_row=True) + print(zero_bounce.domain_search_file_status(sent.file_id)) +except ZBException as e: + print("ZeroBounce bulk finder error: " + str(e)) +``` + +##### List evaluator + +```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) # dict: status, progress, invalid_percentage, ... +zero_bounce.send_evaluated_file("./list.csv") # submit the evaluated list for validation +``` + +##### Allowlist and blocklist filters + +```python +zero_bounce.add_filter("block", "domain", "example.com") # rule: allow | block; target: email | domain | mx | tld +print(zero_bounce.list_filters()) # [{"rule": ..., "target": ..., "value": ...}] +zero_bounce.delete_filter("block", "domain", "example.com") +``` + +##### Inbox Placement Tester + +Tests use the account's inbox-test credits. + +```python +from zerobouncesdk import ZeroBounce, ZBException + +zero_bounce = ZeroBounce("") + +try: + providers = zero_bounce.get_inbox_placement_providers() + test = zero_bounce.create_inbox_placement_test( + "Spring launch", # unique, at most 64 characters + test_type=3, # 1 Email (needs sender_email), 2 Unique ID, 3 Auto Alias + webhook_url="https://example.com/ipt-webhook", + ) + print(test["emailAddressList"]) # send the campaign to these seed addresses + + result = zero_bounce.get_inbox_placement_test(test["id"]) + print(result["status"], result.get("percentages")) + + finished = zero_bounce.list_inbox_placement_tests(status="finished", page_size=25) + zero_bounce.delete_inbox_placement_test(test["id"]) # finished tests only + zero_bounce.set_inbox_placement_webhook("https://example.com/ipt-webhook") + print(zero_bounce.get_inbox_placement_webhook()) +except ZBException as e: + print("ZeroBounce inbox placement error: " + str(e)) +``` + +##### Other hosts + +`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: + +```python +zero_bounce = ZeroBounce("", base_url="https://api-eu.zerobounce.net/v2") +zero_bounce.BULK_BASE_URL = "https://bulkapi.zerobounce.net/v2" # send/status/get/delete file +zero_bounce.SCORING_BASE_URL = "https://bulkapi.zerobounce.net/v2/scoring" +zero_bounce.EMAIL_FINDER_BASE_URL = "https://bulkapi.zerobounce.net/email-finder" +zero_bounce.DOMAIN_SEARCH_BASE_URL = "https://bulkapi.zerobounce.net/domain-search" +zero_bounce.LIST_EVALUATOR_BASE_URL = "https://bulkapi.zerobounce.net/v2/listevaluator" +zero_bounce.INBOX_PLACEMENT_BASE_URL = "https://bulkapi.zerobounce.net/api/ipt" +``` diff --git a/src/zerobouncesdk/zb_validate_response.py b/src/zerobouncesdk/zb_validate_response.py index c8c8789..ef8731b 100644 --- a/src/zerobouncesdk/zb_validate_response.py +++ b/src/zerobouncesdk/zb_validate_response.py @@ -79,8 +79,19 @@ class ZBValidateResponse(ZBResponse): processed_at: datetime = None """The UTC time the email was validated.""" + domain_website_exists: str = None + """With domain_info: whether the domain has a website ("True", "False" or "unchecked").""" + + domain_registrant_company_name: str = None + """With domain_info: the WHOIS registrant company, "Registrant info not found" or "unchecked".""" + + message: str = None + """Set instead of a result while greylist processing is still running; call validate again later.""" + def __init__(self, data): super().__init__(data) + if self.message is None and isinstance(self.__dict__.get("Message"), str): + self.message = self.__dict__["Message"] self.status = safe_enum_convert(ZBValidateStatus, self.status, "status") self.sub_status = safe_enum_convert(ZBValidateSubStatus, self.sub_status, "sub_status") if self.processed_at is not None: diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index caad7b4..372a9fa 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -4,7 +4,7 @@ from datetime import date, timedelta import os from typing import BinaryIO, List, Optional, Union -from urllib.parse import quote_plus +from urllib.parse import quote, quote_plus import requests from requests.exceptions import RequestException @@ -104,7 +104,8 @@ def _message_from_body(body: str, status_code: int) -> str: has_api_message = True if parsed.get("cloudflare_error") is True or not has_api_message: return title - for key in ("message", "error", "error_message"): + # "detail" is how the Inbox Placement Tester (Django REST framework) reports errors. + for key in ("message", "error", "error_message", "detail"): value = parsed.get(key) if isinstance(value, str) and value.strip(): return _strip_error_prefix(value) @@ -153,7 +154,11 @@ class ZeroBounce: EMAIL_FINDER_BASE_URL = "https://bulkapi.zerobounce.net/email-finder" DOMAIN_SEARCH_BASE_URL = "https://bulkapi.zerobounce.net/domain-search" SCORING_BASE_URL = "https://bulkapi.zerobounce.net/v2/scoring" + LIST_EVALUATOR_BASE_URL = "https://bulkapi.zerobounce.net/v2/listevaluator" + INBOX_PLACEMENT_BASE_URL = "https://bulkapi.zerobounce.net/api/ipt" DEFAULT_HTTP_TIMEOUT_S = 120.0 + IPT_TEST_TYPES = (1, 2, 3) + IPT_TEST_STATUSES = ("running", "finished") @staticmethod def _require_https_url(url: str) -> str: @@ -330,7 +335,7 @@ def _post_form(self, url, response_class, data=None): raise type(e)(self._redact(e)) from None return response_class(self._read_json_payload(response)) - def _request_json(self, method: str, url: str, *, params=None, data=None, files=None): + def _request_json(self, method: str, url: str, *, params=None, data=None, files=None, json=None): if params is None: params = {} if "api_key" not in params and (data is None or "api_key" not in data): @@ -343,6 +348,7 @@ def _request_json(self, method: str, url: str, *, params=None, data=None, files= params=params, data=_form_data(data), files=files, + json=json, timeout=self._timeout_s, allow_redirects=False, ) @@ -539,6 +545,9 @@ def validate( activity_data: Optional[bool] = None, verify_plus: Optional[bool] = None, use_post: bool = False, + domain_info: Optional[bool] = None, + greylist_processing: Optional[bool] = None, + callback_url: Optional[str] = None, ): """Validates the given email address. @@ -556,6 +565,15 @@ def validate( When set, sends ``verify_plus`` query parameter. use_post: bool When True, sends parameters as form-encoded POST body. + domain_info: bool or None + When True, the response adds ``domain_website_exists`` and + ``domain_registrant_company_name`` (website availability and WHOIS). + greylist_processing: bool or None + When True, a greylisted address is processed again instead of returned as unknown. + Without ``callback_url``, call ``validate`` again after about 30 minutes to pick up + the result; until then the response's ``message`` says it is still in progress. + callback_url: str or None + With ``greylist_processing=True``, the URL the result is sent to when it is ready. Raises ------ @@ -568,6 +586,10 @@ def validate( Returns a ZBValidateResponse object if the request was successful """ self._check_validate_timeout(timeout) + if callback_url is not None and greylist_processing is not True: + raise ZBClientException("Invalid parameter: callback_url requires greylist_processing=True") + if callback_url is not None and not callback_url.strip(): + raise ZBClientException("Empty parameter: callback_url") params = { "email": email, @@ -579,6 +601,12 @@ def validate( params["activity_data"] = "true" if activity_data else "false" if verify_plus is not None: params["verify_plus"] = "true" if verify_plus else "false" + if domain_info is not None: + params["domain_info"] = "true" if domain_info else "false" + if greylist_processing is not None: + params["greylist_processing"] = "true" if greylist_processing else "false" + if callback_url is not None: + params["callback_url"] = callback_url if use_post: return self._post_form( @@ -1231,7 +1259,7 @@ def send_list_evaluator_file( 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}/listevaluator/", + f"{self.LIST_EVALUATOR_BASE_URL}/", ZBSendFileResponse, data=data, files=files, @@ -1251,7 +1279,7 @@ def send_list_evaluator_file_stream( } files = {"file": (file_name, file_stream, "text/csv")} return self._post( - f"{self.BULK_BASE_URL}/listevaluator/", + f"{self.LIST_EVALUATOR_BASE_URL}/", ZBSendFileResponse, data=data, files=files, @@ -1262,7 +1290,7 @@ def get_list_evaluator_result(self, file_id: str): raise ZBClientException("Empty parameter: file_id") return self._request_json( "GET", - f"{self.BULK_BASE_URL}/listevaluator/{file_id}/", + f"{self.LIST_EVALUATOR_BASE_URL}/{quote(file_id, safe='')}/", params={"api_key": self._api_key}, ) @@ -1414,6 +1442,163 @@ def export_s3file(self, file_id: str, export_bucket: str) -> ZBSendFileResponse: {"file_id": file_id, "export_bucket": export_bucket}, ) + # Inbox Placement Tester (bulkapi /api/ipt). Uses the account's inbox-test credits. + + @staticmethod + def _ipt_test_path(test_id: Union[int, str]) -> str: + if test_id is None or not str(test_id).strip(): + raise ZBClientException("Empty parameter: test_id") + return quote(str(test_id).strip(), safe="") + + def get_inbox_placement_providers(self): + """Seed providers available for new tests (GET /api/ipt/providers). + + Returns + ------- + dict with ``emailProviders`` and ``gatewayProviders``; each has ``items`` of + ``{id, name, location, seedCount, seedIds}``. + """ + return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/providers") + + def get_inbox_placement_test_types(self): + """Supported test types and the default one (GET /api/ipt/test-types).""" + return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/test-types") + + def create_inbox_placement_test( + self, + name: str, + test_type: Optional[int] = None, + sender_email: Optional[str] = None, + provider_ids: Optional[List[int]] = None, + seed_ids: Optional[List[int]] = None, + providers: Optional[List[dict]] = None, + webhook_url: Optional[str] = None, + ): + """Create an inbox placement test (POST /api/ipt/test). Spends one inbox test. + + Parameters + ---------- + name: str + Unique test name, at most 64 characters. + test_type: int or None + 1 (Email, needs ``sender_email``), 2 (Unique ID) or 3 (Auto Alias, the API's default). + sender_email: str or None + The From address to match; required when ``test_type`` is 1. + provider_ids: list of int or None + Use every active seed of these providers. + seed_ids: list of int or None + Use these seed mailboxes. + providers: list of dict or None + ``{"id": provider_id, "maxSeeds": n}`` to cap the seeds per provider (``n`` >= 1). + webhook_url: str or None + Where the finished result is posted; also saved as this API key's webhook. + + Without ``provider_ids``, ``seed_ids`` or ``providers`` the test uses every active seed. + + Returns + ------- + dict with ``id``, ``identifier``, ``testType`` and ``emailAddressList`` (the seeds to send to). + """ + if not name or not name.strip(): + raise ZBClientException("Empty parameter: name") + if len(name) > 64: + raise ZBClientException("Invalid parameter: name (at most 64 characters)") + if test_type is not None and test_type not in self.IPT_TEST_TYPES: + raise ZBClientException("Invalid parameter: test_type (must be 1, 2 or 3)") + if test_type == 1 and (not sender_email or not sender_email.strip()): + raise ZBClientException("Empty parameter: sender_email (required when test_type is 1)") + for provider in providers or []: + if not isinstance(provider, dict) or "id" not in provider: + raise ZBClientException("Invalid parameter: providers (each needs an id)") + max_seeds = provider.get("maxSeeds") + if max_seeds is not None and max_seeds < 1: + raise ZBClientException("Invalid parameter: providers (maxSeeds must be at least 1)") + + body = {"name": name} + if test_type is not None: + body["testType"] = test_type + if sender_email is not None: + body["senderEmail"] = sender_email + if provider_ids is not None: + body["providerIds"] = list(provider_ids) + if seed_ids is not None: + body["seedIds"] = list(seed_ids) + if providers is not None: + body["providers"] = list(providers) + if webhook_url is not None: + body["webhookUrl"] = webhook_url + return self._request_json("POST", f"{self.INBOX_PLACEMENT_BASE_URL}/test", json=body) + + def get_inbox_placement_test(self, test_id: Union[int, str]): + """A test and its placement results (GET /api/ipt/test/{id}). + + Returns + ------- + dict with ``status`` (running or finished), inbox/spam/other/not delivered/pending + counts, ``percentages``, ``results`` and ``seeds``. + """ + path = self._ipt_test_path(test_id) + return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/test/{path}") + + def list_inbox_placement_tests( + self, + status: Optional[str] = None, + created_after: Optional[Union[date, str]] = None, + created_before: Optional[Union[date, str]] = None, + page: Optional[int] = None, + page_size: Optional[int] = None, + ): + """List tests, newest first (GET /api/ipt/tests). + + Parameters + ---------- + status: str or None + ``running`` or ``finished``. + created_after, created_before: date, datetime, ISO string or None + Only tests created in this range. + page: int or None + Page number, from 1. + page_size: int or None + Tests per page, 1 to 100 (the API's default is 25). + """ + if status is not None and status not in self.IPT_TEST_STATUSES: + raise ZBClientException("Invalid parameter: status (must be running or finished)") + if page is not None and page < 1: + raise ZBClientException("Invalid parameter: page (must be at least 1)") + if page_size is not None and (page_size < 1 or page_size > 100): + raise ZBClientException("Invalid parameter: page_size (must be between 1 and 100)") + + params = {} + if status is not None: + params["status"] = status + for key, value in (("createdAfter", created_after), ("createdBefore", created_before)): + if value is not None: + params[key] = value.isoformat() if isinstance(value, date) else value + if page is not None: + params["page"] = page + if page_size is not None: + params["page_size"] = page_size + return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/tests", params=params) + + def delete_inbox_placement_test(self, test_id: Union[int, str]): + """Delete a finished test (DELETE /api/ipt/test/{id}). A running test cannot be deleted.""" + path = self._ipt_test_path(test_id) + return self._request_json("DELETE", f"{self.INBOX_PLACEMENT_BASE_URL}/test/{path}") + + def get_inbox_placement_webhook(self): + """The webhook URL saved on this API key (GET /api/ipt/webhook); empty when none is set.""" + return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/webhook") + + def set_inbox_placement_webhook(self, webhook_url: str): + """Save the URL finished test results are posted to (PUT /api/ipt/webhook). One per API key.""" + if not webhook_url or not webhook_url.strip(): + raise ZBClientException("Empty parameter: webhook_url") + return self._request_json( + "PUT", + f"{self.INBOX_PLACEMENT_BASE_URL}/webhook", + json={"webhookUrl": webhook_url}, + ) + def guess_format( self, domain: str, diff --git a/tests/test_offline_api.py b/tests/test_offline_api.py index 9154bd6..2e24cd0 100644 --- a/tests/test_offline_api.py +++ b/tests/test_offline_api.py @@ -26,6 +26,7 @@ ZBClientException, ZBGetFileOptions, ZBValidateBatchElement, + ZBValidateResponse, ZeroBounce, ) @@ -172,6 +173,8 @@ def _account_error(self, recorded, key): payload = {"success": "False", "message": ["api_key is invalid"]} status = 401 return _response(status, json.dumps(payload).encode(), "application/json") + if "/api/ipt/" in path: + return _response(401, json.dumps({"detail": "Invalid API key."}).encode(), "application/json") if "/filters/" in path: payload = {"Error": text} return _response(200, json.dumps(payload).encode(), "application/json") @@ -333,6 +336,14 @@ def test_every_public_method_hits_the_right_endpoint(self): ("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"), ("find_domain", lambda: self.client.find_domain(domain="example.com"), "GET", "api.zerobounce.net", "/v2/guessformat"), + ("get_inbox_placement_providers", lambda: self.client.get_inbox_placement_providers(), "GET", "bulkapi.zerobounce.net", "/api/ipt/providers"), + ("get_inbox_placement_test_types", lambda: self.client.get_inbox_placement_test_types(), "GET", "bulkapi.zerobounce.net", "/api/ipt/test-types"), + ("create_inbox_placement_test", lambda: self.client.create_inbox_placement_test("Spring launch"), "POST", "bulkapi.zerobounce.net", "/api/ipt/test"), + ("get_inbox_placement_test", lambda: self.client.get_inbox_placement_test(42), "GET", "bulkapi.zerobounce.net", "/api/ipt/test/42"), + ("list_inbox_placement_tests", lambda: self.client.list_inbox_placement_tests(), "GET", "bulkapi.zerobounce.net", "/api/ipt/tests"), + ("delete_inbox_placement_test", lambda: self.client.delete_inbox_placement_test("42"), "DELETE", "bulkapi.zerobounce.net", "/api/ipt/test/42"), + ("get_inbox_placement_webhook", lambda: self.client.get_inbox_placement_webhook(), "GET", "bulkapi.zerobounce.net", "/api/ipt/webhook"), + ("set_inbox_placement_webhook", lambda: self.client.set_inbox_placement_webhook("https://hooks.example.com/ipt"), "PUT", "bulkapi.zerobounce.net", "/api/ipt/webhook"), ] for name, call, method, host, path in cases: self.transport.requests.clear() @@ -379,6 +390,111 @@ def test_optional_parameters_are_omitted_until_set(self): self.assertEqual(recorded.query["activity_data"], "false") self.assertEqual(recorded.query["verify_plus"], "false") + def test_validate_domain_info_and_greylist_options(self): + self.client.validate("ada@example.com") + recorded = self._only() + for name in ("domain_info", "greylist_processing", "callback_url"): + self.assertNotIn(name, recorded.query) + + self.transport.requests.clear() + self.client.validate("ada@example.com", domain_info=True, greylist_processing=False) + recorded = self._only() + self.assertEqual(recorded.query["domain_info"], "true") + self.assertEqual(recorded.query["greylist_processing"], "false") + self.assertNotIn("callback_url", recorded.query) + + self.transport.requests.clear() + self.client.validate( + "ada@example.com", + greylist_processing=True, + callback_url="https://hooks.example.com/zb?x=1", + use_post=True, + ) + recorded = self._only() + self.assertEqual(recorded.form["greylist_processing"], "true") + self.assertEqual(recorded.form["callback_url"], "https://hooks.example.com/zb?x=1") + + def test_validate_response_reads_domain_info_and_greylist_message(self): + result = ZBValidateResponse( + { + "address": "ada@example.com", + "status": "valid", + "domain_website_exists": "True", + "domain_registrant_company_name": "Example Inc", + } + ) + self.assertEqual(result.domain_website_exists, "True") + self.assertEqual(result.domain_registrant_company_name, "Example Inc") + self.assertIsNone(result.message) + + pending = ZBValidateResponse({"Message": "Greylist validation still in progress. Please try again later."}) + self.assertIsNone(pending.status) + self.assertIn("still in progress", pending.message) + + def test_list_evaluator_base_url_is_separate_and_file_id_is_encoded(self): + client = ZeroBounce("client-key") + client.LIST_EVALUATOR_BASE_URL = "https://test-members-api.zerobounce.net/api/validation/list-evaluator" + client.get_list_evaluator_result("a/b?c") + recorded = self._only() + self.assertEqual(recorded.host, "test-members-api.zerobounce.net") + self.assertIn("/api/validation/list-evaluator/a%2Fb%3Fc/", recorded.url) + + self.transport.requests.clear() + client.send_evaluated_file(str(self._csv())) + self.assertEqual(self._only().path, "/v2/send-evaluated-file/") + + def test_inbox_placement_create_sends_camel_case_json(self): + self.client.create_inbox_placement_test("Spring launch") + recorded = self._only() + self.assertEqual(recorded.json, {"name": "Spring launch"}) + self.assertEqual(recorded.query["api_key"], "client-key") + + self.transport.requests.clear() + self.client.create_inbox_placement_test( + "From match", + test_type=1, + sender_email="news@example.com", + provider_ids=[1, 2], + seed_ids=[7], + providers=[{"id": 1, "maxSeeds": 3}], + webhook_url="https://hooks.example.com/ipt", + ) + self.assertEqual( + self._only().json, + { + "name": "From match", + "testType": 1, + "senderEmail": "news@example.com", + "providerIds": [1, 2], + "seedIds": [7], + "providers": [{"id": 1, "maxSeeds": 3}], + "webhookUrl": "https://hooks.example.com/ipt", + }, + ) + + self.transport.requests.clear() + self.client.set_inbox_placement_webhook("https://hooks.example.com/ipt") + self.assertEqual(self._only().json, {"webhookUrl": "https://hooks.example.com/ipt"}) + + def test_inbox_placement_list_filters(self): + self.client.list_inbox_placement_tests() + self.assertEqual(self._only().query, {"api_key": "client-key"}) + + self.transport.requests.clear() + self.client.list_inbox_placement_tests( + status="finished", + created_after=date(2026, 10, 1), + created_before="2026-10-23T00:00:00Z", + page=2, + page_size=100, + ) + recorded = self._only() + self.assertEqual(recorded.query["status"], "finished") + self.assertEqual(recorded.query["createdAfter"], "2026-10-01") + self.assertEqual(recorded.query["createdBefore"], "2026-10-23T00:00:00Z") + self.assertEqual(recorded.query["page"], "2") + self.assertEqual(recorded.query["page_size"], "100") + def test_query_values_are_percent_encoded(self): client = ZeroBounce("key with space&plus") client.validate("a b@ex.com") @@ -471,6 +587,21 @@ def test_invalid_arguments_send_no_request(self): lambda: self.client.export_s3file("file-9", " "), lambda: self.client.get_list_evaluator_result(" "), lambda: self.client.email_finder_file_status(" "), + lambda: self.client.validate("a@b.com", callback_url="https://hooks.example.com/zb"), + lambda: self.client.validate("a@b.com", greylist_processing=True, callback_url=" "), + lambda: self.client.create_inbox_placement_test(" "), + lambda: self.client.create_inbox_placement_test("x" * 65), + lambda: self.client.create_inbox_placement_test("t", test_type=4), + lambda: self.client.create_inbox_placement_test("t", test_type=1), + lambda: self.client.create_inbox_placement_test("t", providers=[{"maxSeeds": 2}]), + lambda: self.client.create_inbox_placement_test("t", providers=[{"id": 1, "maxSeeds": 0}]), + lambda: self.client.get_inbox_placement_test(" "), + lambda: self.client.get_inbox_placement_test(None), + lambda: self.client.delete_inbox_placement_test(""), + lambda: self.client.list_inbox_placement_tests(status="pending"), + lambda: self.client.list_inbox_placement_tests(page=0), + lambda: self.client.list_inbox_placement_tests(page_size=101), + lambda: self.client.set_inbox_placement_webhook(" "), ] for attempt in attempts: self.transport.requests.clear() @@ -507,6 +638,10 @@ def test_account_errors_raise_a_short_message(self): lambda: client.email_finder_file_status("file-9"), lambda: client.email_finder_get_file("file-9", str(out) + ".f"), lambda: client.domain_search_get_file("file-9", str(out) + ".d"), + lambda: client.get_inbox_placement_providers(), + lambda: client.create_inbox_placement_test("Spring launch"), + lambda: client.get_inbox_placement_test(42), + lambda: client.delete_inbox_placement_test(42), lambda: ZeroBounce("nocredits-key").validate("a@b.com"), ] for call in raised: @@ -525,6 +660,10 @@ def test_account_errors_raise_a_short_message(self): client.validate_batch([ZBValidateBatchElement("a@b.com")]) self.assertIn("Invalid API Key", str(caught.exception)) + with self.assertRaises(ZBApiException) as caught: + client.get_inbox_placement_webhook() + self.assertEqual(str(caught.exception), "Invalid API key.") + with self.assertRaises(ZBApiException) as caught: client.list_s3files() self.assertEqual(str(caught.exception), "api_key is invalid") From a6710e575220c219f5e403d1184f17614687bcb8 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:00:48 +0400 Subject: [PATCH 09/18] Remove the S3 methods: they are not in the public API docs list_s3files, validate_s3file, score_s3file and export_s3file call endpoints that https://www.zerobounce.net/docs/ does not document. The SDK only covers documented endpoints (now a rule in AGENTS.md). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- CHANGELOG.md | 3 +- src/zerobouncesdk/zerobouncesdk.py | 104 ----------------------------- tests/test_offline_api.py | 25 +------ tests/zero_bounce_test_case.py | 17 ----- 5 files changed, 7 insertions(+), 147 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5f0546b..51c7735 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,11 +31,12 @@ CI (`.github/workflows/sdk_ci.yml`) runs `ruff check`, `compileall`, then `pytho ## 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. - **One `ZeroBounce` per API key.** The class is not a singleton. Do not reuse one instance across users or requests. The offline concurrency test constructs a separate client per key. -- **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 (`first_name_column`, and the S3 email 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. +- **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. - **Optional parameters are omitted when unset.** `timeout`, `activity_data`, `verify_plus`, 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`) keep JSON booleans. -- **`allow_phase_2` is validation `send_file` and `validate_s3file` only.** Scoring `send_file` and `score_s3file` do not send it. `activity_data` on get-file is validation `get_file` only. +- **`allow_phase_2` is validation `send_file` only.** Scoring `send_file` does not send it. `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. - **A file download is not JSON.** `get_file` saves a non-empty CSV body, including `text/csv` and `text/plain`. An empty body, an HTML page (status 200 or 403), and a JSON error are `ZBApiException`. Do not treat every `text/plain` body as an error. diff --git a/CHANGELOG.md b/CHANGELOG.md index d0594be..ae4408d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). - Filters (`add_filter`, `list_filters`, `delete_filter`). -- S3 bulk (`validate_s3file`, `score_s3file`, `export_s3file`, `list_s3files`). - `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. - `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. @@ -33,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A 403 HTML "Access denied" page (about 1.4 MB from the live API on `get_file`) is raised as a short `ZBApiException`. An HTML page with status 200 is an error as well, including `get_file`, `email_finder_get_file`, and `domain_search_get_file`, instead of a saved CSV. - Empty and non-JSON bodies are a short `ZBApiException`. The message is no longer a chained `JSONDecodeError`, and a doubled `Error: Error:` / `[Error]: [Error]:` prefix is collapsed to one. - `validate_batch` raises `ZBApiException` 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. -- `send_file`, `scoring_send_file`, bulk email finder, domain search, and S3 uploads send `has_header_row`, `remove_duplicate`, and `remove_duplicates` as `true` or `false`. `has_header_row=False` is sent. +- `send_file`, `scoring_send_file`, bulk email finder, and domain search uploads send `has_header_row`, `remove_duplicate`, and `remove_duplicates` as `true` or `false`. `has_header_row=False` is sent. - `send_email_finder_file`, `send_domain_search_file`, and `send_list_evaluator_file` send column `0`. They previously treated `0` as empty and rejected it before the request. - `find_email_format` raises `ZBApiException` when the API returns a `Message` error, the same way `find_domain` and `guess_format` already did. diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 372a9fa..52613a4 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -150,7 +150,6 @@ class ZeroBounce: """The ZeroBounce main class. All the requests are implemented here.""" BULK_BASE_URL = "https://bulkapi.zerobounce.net/v2" - BULK_ROOT_URL = "https://bulkapi.zerobounce.net" EMAIL_FINDER_BASE_URL = "https://bulkapi.zerobounce.net/email-finder" DOMAIN_SEARCH_BASE_URL = "https://bulkapi.zerobounce.net/domain-search" SCORING_BASE_URL = "https://bulkapi.zerobounce.net/v2/scoring" @@ -420,15 +419,6 @@ def _bulk_root_delete_file(self, base_url: str, file_id: str) -> ZBDeleteFileRes params={"file_id": file_id}, ) - def _post_bulk_root_form(self, path: str, fields: dict) -> ZBSendFileResponse: - data = dict(fields) - data["api_key"] = self._api_key - return self._post( - f"{self.BULK_ROOT_URL}{path}", - ZBSendFileResponse, - data=data, - ) - def get_credits(self): """Tells you how many credits you have left on your account. It's simple, fast and easy to use. @@ -1348,100 +1338,6 @@ def delete_filter(self, rule: str, target: str, value: str): }, ) - # Priority 3 — S3 bulk helpers (bulkapi root) - - def list_s3files( - self, - bucket: str = None, - search: str = None, - ): - """List S3 bucket files available for validation or scoring (GET /listS3files).""" - params = {} - if bucket is not None: - params["bucket"] = bucket - if search is not None: - params["search"] = search - payload = self._request_json( - "GET", - f"{self.BULK_ROOT_URL}/listS3files", - params=params, - ) - if isinstance(payload, dict) and payload.get("error"): - raise ZBApiException(payload["error"]) - if not isinstance(payload, list): - raise ZBApiException("Unexpected listS3files response") - return payload - - def validate_s3file( - self, - file_name: str, - has_header_row: bool = None, - remove_duplicates: bool = None, - remove_duplicate: bool = None, - email_address_column: int = None, - return_url: str = None, - first_name_column: int = None, - last_name_column: int = None, - gender_column: int = None, - ip_address_column: int = None, - allow_phase_2: Optional[bool] = None, - ) -> ZBSendFileResponse: - if not file_name or not file_name.strip(): - raise ZBClientException("Empty parameter: file_name") - fields = {"file_name": file_name} - if has_header_row is not None: - fields["has_header_row"] = has_header_row - if remove_duplicates is not None: - fields["remove_duplicates"] = remove_duplicates - if remove_duplicate is not None: - fields["remove_duplicate"] = remove_duplicate - if email_address_column is not None: - fields["email_address_column"] = email_address_column - if return_url is not None: - fields["return_url"] = return_url - if first_name_column is not None: - fields["first_name_column"] = first_name_column - if last_name_column is not None: - fields["last_name_column"] = last_name_column - if gender_column is not None: - fields["gender_column"] = gender_column - if ip_address_column is not None: - fields["ip_address_column"] = ip_address_column - if allow_phase_2 is not None: - fields["allow_phase_2"] = "true" if allow_phase_2 else "false" - return self._post_bulk_root_form("/validateS3file", fields) - - def score_s3file( - self, - file_name: str, - has_header_row: bool = None, - remove_duplicates: bool = None, - remove_duplicate: bool = None, - email_address_column: int = None, - ) -> ZBSendFileResponse: - if not file_name or not file_name.strip(): - raise ZBClientException("Empty parameter: file_name") - fields = {"file_name": file_name} - if has_header_row is not None: - fields["has_header_row"] = has_header_row - if remove_duplicates is not None: - fields["remove_duplicates"] = remove_duplicates - if remove_duplicate is not None: - fields["remove_duplicate"] = remove_duplicate - if email_address_column is not None: - fields["email_address_column"] = email_address_column - return self._post_bulk_root_form("/scoreS3file", fields) - - def export_s3file(self, file_id: str, export_bucket: str) -> ZBSendFileResponse: - if not file_id.strip(): - raise ZBClientException("Empty parameter: file_id") - if not export_bucket or not export_bucket.strip(): - raise ZBClientException("Empty parameter: export_bucket") - return self._post_bulk_root_form( - "/exportS3file", - {"file_id": file_id, "export_bucket": export_bucket}, - ) - # Inbox Placement Tester (bulkapi /api/ipt). Uses the account's inbox-test credits. @staticmethod diff --git a/tests/test_offline_api.py b/tests/test_offline_api.py index 2e24cd0..7e95fe4 100644 --- a/tests/test_offline_api.py +++ b/tests/test_offline_api.py @@ -169,7 +169,7 @@ def _account_error(self, recorded, key): if path.endswith("/sendfile") or path.rstrip("/").endswith("listevaluator") or "send-evaluated-file" in path: payload = {"success": "False", "message": ["api_key is invalid"]} return _response(401, json.dumps(payload).encode(), "application/json") - if path.endswith("/listS3files") or "S3file" in path or path.rstrip("/").endswith("listevaluator") or "/listevaluator/" in path: + if path.rstrip("/").endswith("listevaluator") or "/listevaluator/" in path: payload = {"success": "False", "message": ["api_key is invalid"]} status = 401 return _response(status, json.dumps(payload).encode(), "application/json") @@ -215,7 +215,7 @@ def _success(self, recorded, key): } if path.endswith("/deletefile"): return {"success": True, "message": "File Deleted", "file_id": recorded.field("file_id"), "file_name": "emails.csv", **echo} - if path.endswith("/sendfile") or "listevaluator" in path or "send-evaluated-file" in path or path.endswith("S3file"): + if path.endswith("/sendfile") or "listevaluator" in path or "send-evaluated-file" in path: return {"success": True, "message": "File Accepted", "file_id": "file-1", "file_name": "emails.csv", **echo} if path.endswith("/guessformat"): return { @@ -234,8 +234,6 @@ def _success(self, recorded, key): } if "/filters/" in path: return {"Message": "Filter successfully added", **echo} - if path.endswith("/listS3files"): - return [{"name": "uploads/a.csv", "echo_key": key}] if "/listevaluator/" in path: return {"file_status": "Complete", **echo} return {"ok": True, **echo} @@ -329,10 +327,6 @@ def test_every_public_method_hits_the_right_endpoint(self): ("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"), "POST", "bulkapi.zerobounce.net", "/validateS3file"), - ("score_s3file", lambda: self.client.score_s3file("lists/jan.csv"), "POST", "bulkapi.zerobounce.net", "/scoreS3file"), - ("export_s3file", lambda: self.client.export_s3file("file-9", "export-bucket"), "POST", "bulkapi.zerobounce.net", "/exportS3file"), ("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"), ("find_domain", lambda: self.client.find_domain(domain="example.com"), "GET", "api.zerobounce.net", "/v2/guessformat"), @@ -533,15 +527,6 @@ def test_column_zero_is_sent_and_unset_columns_are_left_out(self): self.client.send_list_evaluator_file(str(csv), 0) self.assertEqual(self._only().form["email_address_column"], "0") - self.transport.requests.clear() - self.client.validate_s3file("a.csv", email_address_column=0, has_header_row=False, remove_duplicate=False, allow_phase_2=False) - recorded = self._only() - self.assertEqual(recorded.form["email_address_column"], "0") - self.assertEqual(recorded.form["has_header_row"], "false") - self.assertEqual(recorded.form["remove_duplicate"], "false") - self.assertEqual(recorded.form["allow_phase_2"], "false") - self.assertNotIn("remove_duplicates", recorded.form) - self.transport.requests.clear() self.client.scoring_send_file(str(csv), 1, has_header_row=True, remove_duplicate=True) recorded = self._only() @@ -582,9 +567,6 @@ 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(" "), - lambda: self.client.export_s3file(" ", "bucket"), - lambda: self.client.export_s3file("file-9", " "), lambda: self.client.get_list_evaluator_result(" "), lambda: self.client.email_finder_file_status(" "), lambda: self.client.validate("a@b.com", callback_url="https://hooks.example.com/zb"), @@ -630,7 +612,6 @@ def test_account_errors_raise_a_short_message(self): lambda: client.send_file(str(csv), 1), lambda: client.list_filters(), lambda: client.add_filter("block", "domain", "x.com"), - lambda: client.list_s3files(), lambda: client.find_email_format("Ada", domain="example.com"), lambda: client.find_domain(domain="example.com"), lambda: client.guess_format("example.com"), @@ -665,7 +646,7 @@ def test_account_errors_raise_a_short_message(self): self.assertEqual(str(caught.exception), "Invalid API key.") with self.assertRaises(ZBApiException) as caught: - client.list_s3files() + client.send_file(str(csv), 1) self.assertEqual(str(caught.exception), "api_key is invalid") with self.assertRaises(ZBApiException) as caught: diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index 76f5664..5af390e 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -174,15 +174,6 @@ def test_validate_post_uses_form_body(self): self.assertEqual(data["email"], "a@b.com") self.assertEqual(data["timeout"], 30) - def test_list_s3files_gets_bulk_root(self): - self.requests_mock.request.return_value = MockResponse( - [{"name": "uploads/a.csv", "size": 10, "last_modified": "2024-01-01T00:00:00Z"}] - ) - result = self.zero_bounce_client.list_s3files(bucket="my-bucket", search="a") - self.assertEqual(result[0]["name"], "uploads/a.csv") - called_url = self.requests_mock.request.call_args[0][1] - self.assertIn("bulkapi.zerobounce.net/listS3files", called_url) - def test_validate_timeout_out_of_range(self): with self.assertRaises(ZBClientException): self.zero_bounce_client.validate("a@b.com", timeout=2) @@ -763,14 +754,6 @@ def test_add_filter_posts_to_main_api(self): data = self.requests_mock.request.call_args[1]["data"] self.assertEqual(data["rule"], "allow") - 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 - ) - url = self.requests_mock.post.call_args[0][0] - self.assertEqual(url, "https://bulkapi.zerobounce.net/validateS3file") - def test_get_list_evaluator_result_url(self): self.requests_mock.request.return_value = MockResponse({"file_status": "Complete"}) self.zero_bounce_client.get_list_evaluator_result("abc-123") From d16d737b4db110e693f35f715a60447e8c09a69d Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:33:57 +0400 Subject: [PATCH 10/18] Restore the S3 methods: the S3 bulk API is documented Reverts a6710e5. list_s3files, validate_s3file, score_s3file and export_s3file are documented at https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, an Integrations page the earlier check missed. AGENTS.md keeps the rule that the SDK covers only documented endpoints, and now says integration pages count. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- CHANGELOG.md | 3 +- src/zerobouncesdk/zerobouncesdk.py | 104 +++++++++++++++++++++++++++++ tests/test_offline_api.py | 25 ++++++- tests/zero_bounce_test_case.py | 17 +++++ 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 51c7735..aa91340 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,12 +31,12 @@ CI (`.github/workflows/sdk_ci.yml`) runs `ruff check`, `compileall`, then `pytho ## 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. +- **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. Integration pages count: the S3 bulk API is documented at https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api. - **One `ZeroBounce` per API key.** The class is not a singleton. Do not reuse one instance across users or requests. The offline concurrency test constructs a separate client per key. -- **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. +- **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 (`first_name_column`, and the S3 email 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. - **Optional parameters are omitted when unset.** `timeout`, `activity_data`, `verify_plus`, 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`) keep JSON booleans. -- **`allow_phase_2` is validation `send_file` only.** Scoring `send_file` does not send it. `activity_data` on get-file is validation `get_file` only. +- **`allow_phase_2` is validation `send_file` and `validate_s3file` only.** Scoring `send_file` and `score_s3file` do not send it. `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. - **A file download is not JSON.** `get_file` saves a non-empty CSV body, including `text/csv` and `text/plain`. An empty body, an HTML page (status 200 or 403), and a JSON error are `ZBApiException`. Do not treat every `text/plain` body as an error. diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4408d..d0594be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). - Filters (`add_filter`, `list_filters`, `delete_filter`). +- S3 bulk (`validate_s3file`, `score_s3file`, `export_s3file`, `list_s3files`). - `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. - `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. @@ -32,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A 403 HTML "Access denied" page (about 1.4 MB from the live API on `get_file`) is raised as a short `ZBApiException`. An HTML page with status 200 is an error as well, including `get_file`, `email_finder_get_file`, and `domain_search_get_file`, instead of a saved CSV. - Empty and non-JSON bodies are a short `ZBApiException`. The message is no longer a chained `JSONDecodeError`, and a doubled `Error: Error:` / `[Error]: [Error]:` prefix is collapsed to one. - `validate_batch` raises `ZBApiException` 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. -- `send_file`, `scoring_send_file`, bulk email finder, and domain search uploads send `has_header_row`, `remove_duplicate`, and `remove_duplicates` as `true` or `false`. `has_header_row=False` is sent. +- `send_file`, `scoring_send_file`, bulk email finder, domain search, and S3 uploads send `has_header_row`, `remove_duplicate`, and `remove_duplicates` as `true` or `false`. `has_header_row=False` is sent. - `send_email_finder_file`, `send_domain_search_file`, and `send_list_evaluator_file` send column `0`. They previously treated `0` as empty and rejected it before the request. - `find_email_format` raises `ZBApiException` when the API returns a `Message` error, the same way `find_domain` and `guess_format` already did. diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 52613a4..372a9fa 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -150,6 +150,7 @@ class ZeroBounce: """The ZeroBounce main class. All the requests are implemented here.""" BULK_BASE_URL = "https://bulkapi.zerobounce.net/v2" + BULK_ROOT_URL = "https://bulkapi.zerobounce.net" EMAIL_FINDER_BASE_URL = "https://bulkapi.zerobounce.net/email-finder" DOMAIN_SEARCH_BASE_URL = "https://bulkapi.zerobounce.net/domain-search" SCORING_BASE_URL = "https://bulkapi.zerobounce.net/v2/scoring" @@ -419,6 +420,15 @@ def _bulk_root_delete_file(self, base_url: str, file_id: str) -> ZBDeleteFileRes params={"file_id": file_id}, ) + def _post_bulk_root_form(self, path: str, fields: dict) -> ZBSendFileResponse: + data = dict(fields) + data["api_key"] = self._api_key + return self._post( + f"{self.BULK_ROOT_URL}{path}", + ZBSendFileResponse, + data=data, + ) + def get_credits(self): """Tells you how many credits you have left on your account. It's simple, fast and easy to use. @@ -1338,6 +1348,100 @@ def delete_filter(self, rule: str, target: str, value: str): }, ) + # Priority 3 — S3 bulk helpers (bulkapi root) + + def list_s3files( + self, + bucket: str = None, + search: str = None, + ): + """List S3 bucket files available for validation or scoring (GET /listS3files).""" + params = {} + if bucket is not None: + params["bucket"] = bucket + if search is not None: + params["search"] = search + payload = self._request_json( + "GET", + f"{self.BULK_ROOT_URL}/listS3files", + params=params, + ) + if isinstance(payload, dict) and payload.get("error"): + raise ZBApiException(payload["error"]) + if not isinstance(payload, list): + raise ZBApiException("Unexpected listS3files response") + return payload + + def validate_s3file( + self, + file_name: str, + has_header_row: bool = None, + remove_duplicates: bool = None, + remove_duplicate: bool = None, + email_address_column: int = None, + return_url: str = None, + first_name_column: int = None, + last_name_column: int = None, + gender_column: int = None, + ip_address_column: int = None, + allow_phase_2: Optional[bool] = None, + ) -> ZBSendFileResponse: + if not file_name or not file_name.strip(): + raise ZBClientException("Empty parameter: file_name") + fields = {"file_name": file_name} + if has_header_row is not None: + fields["has_header_row"] = has_header_row + if remove_duplicates is not None: + fields["remove_duplicates"] = remove_duplicates + if remove_duplicate is not None: + fields["remove_duplicate"] = remove_duplicate + if email_address_column is not None: + fields["email_address_column"] = email_address_column + if return_url is not None: + fields["return_url"] = return_url + if first_name_column is not None: + fields["first_name_column"] = first_name_column + if last_name_column is not None: + fields["last_name_column"] = last_name_column + if gender_column is not None: + fields["gender_column"] = gender_column + if ip_address_column is not None: + fields["ip_address_column"] = ip_address_column + if allow_phase_2 is not None: + fields["allow_phase_2"] = "true" if allow_phase_2 else "false" + return self._post_bulk_root_form("/validateS3file", fields) + + def score_s3file( + self, + file_name: str, + has_header_row: bool = None, + remove_duplicates: bool = None, + remove_duplicate: bool = None, + email_address_column: int = None, + ) -> ZBSendFileResponse: + if not file_name or not file_name.strip(): + raise ZBClientException("Empty parameter: file_name") + fields = {"file_name": file_name} + if has_header_row is not None: + fields["has_header_row"] = has_header_row + if remove_duplicates is not None: + fields["remove_duplicates"] = remove_duplicates + if remove_duplicate is not None: + fields["remove_duplicate"] = remove_duplicate + if email_address_column is not None: + fields["email_address_column"] = email_address_column + return self._post_bulk_root_form("/scoreS3file", fields) + + def export_s3file(self, file_id: str, export_bucket: str) -> ZBSendFileResponse: + if not file_id.strip(): + raise ZBClientException("Empty parameter: file_id") + if not export_bucket or not export_bucket.strip(): + raise ZBClientException("Empty parameter: export_bucket") + return self._post_bulk_root_form( + "/exportS3file", + {"file_id": file_id, "export_bucket": export_bucket}, + ) + # Inbox Placement Tester (bulkapi /api/ipt). Uses the account's inbox-test credits. @staticmethod diff --git a/tests/test_offline_api.py b/tests/test_offline_api.py index 7e95fe4..2e24cd0 100644 --- a/tests/test_offline_api.py +++ b/tests/test_offline_api.py @@ -169,7 +169,7 @@ def _account_error(self, recorded, key): if path.endswith("/sendfile") or path.rstrip("/").endswith("listevaluator") or "send-evaluated-file" in path: payload = {"success": "False", "message": ["api_key is invalid"]} return _response(401, json.dumps(payload).encode(), "application/json") - if path.rstrip("/").endswith("listevaluator") or "/listevaluator/" in path: + if path.endswith("/listS3files") or "S3file" in path or path.rstrip("/").endswith("listevaluator") or "/listevaluator/" in path: payload = {"success": "False", "message": ["api_key is invalid"]} status = 401 return _response(status, json.dumps(payload).encode(), "application/json") @@ -215,7 +215,7 @@ def _success(self, recorded, key): } if path.endswith("/deletefile"): return {"success": True, "message": "File Deleted", "file_id": recorded.field("file_id"), "file_name": "emails.csv", **echo} - if path.endswith("/sendfile") or "listevaluator" in path or "send-evaluated-file" in path: + if path.endswith("/sendfile") or "listevaluator" in path or "send-evaluated-file" in path or path.endswith("S3file"): return {"success": True, "message": "File Accepted", "file_id": "file-1", "file_name": "emails.csv", **echo} if path.endswith("/guessformat"): return { @@ -234,6 +234,8 @@ def _success(self, recorded, key): } if "/filters/" in path: return {"Message": "Filter successfully added", **echo} + if path.endswith("/listS3files"): + return [{"name": "uploads/a.csv", "echo_key": key}] if "/listevaluator/" in path: return {"file_status": "Complete", **echo} return {"ok": True, **echo} @@ -327,6 +329,10 @@ def test_every_public_method_hits_the_right_endpoint(self): ("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"), "POST", "bulkapi.zerobounce.net", "/validateS3file"), + ("score_s3file", lambda: self.client.score_s3file("lists/jan.csv"), "POST", "bulkapi.zerobounce.net", "/scoreS3file"), + ("export_s3file", lambda: self.client.export_s3file("file-9", "export-bucket"), "POST", "bulkapi.zerobounce.net", "/exportS3file"), ("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"), ("find_domain", lambda: self.client.find_domain(domain="example.com"), "GET", "api.zerobounce.net", "/v2/guessformat"), @@ -527,6 +533,15 @@ def test_column_zero_is_sent_and_unset_columns_are_left_out(self): self.client.send_list_evaluator_file(str(csv), 0) self.assertEqual(self._only().form["email_address_column"], "0") + self.transport.requests.clear() + self.client.validate_s3file("a.csv", email_address_column=0, has_header_row=False, remove_duplicate=False, allow_phase_2=False) + recorded = self._only() + self.assertEqual(recorded.form["email_address_column"], "0") + self.assertEqual(recorded.form["has_header_row"], "false") + self.assertEqual(recorded.form["remove_duplicate"], "false") + self.assertEqual(recorded.form["allow_phase_2"], "false") + self.assertNotIn("remove_duplicates", recorded.form) + self.transport.requests.clear() self.client.scoring_send_file(str(csv), 1, has_header_row=True, remove_duplicate=True) recorded = self._only() @@ -567,6 +582,9 @@ 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(" "), + lambda: self.client.export_s3file(" ", "bucket"), + lambda: self.client.export_s3file("file-9", " "), lambda: self.client.get_list_evaluator_result(" "), lambda: self.client.email_finder_file_status(" "), lambda: self.client.validate("a@b.com", callback_url="https://hooks.example.com/zb"), @@ -612,6 +630,7 @@ def test_account_errors_raise_a_short_message(self): lambda: client.send_file(str(csv), 1), lambda: client.list_filters(), lambda: client.add_filter("block", "domain", "x.com"), + lambda: client.list_s3files(), lambda: client.find_email_format("Ada", domain="example.com"), lambda: client.find_domain(domain="example.com"), lambda: client.guess_format("example.com"), @@ -646,7 +665,7 @@ def test_account_errors_raise_a_short_message(self): self.assertEqual(str(caught.exception), "Invalid API key.") with self.assertRaises(ZBApiException) as caught: - client.send_file(str(csv), 1) + client.list_s3files() self.assertEqual(str(caught.exception), "api_key is invalid") with self.assertRaises(ZBApiException) as caught: diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index 5af390e..76f5664 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -174,6 +174,15 @@ def test_validate_post_uses_form_body(self): self.assertEqual(data["email"], "a@b.com") self.assertEqual(data["timeout"], 30) + def test_list_s3files_gets_bulk_root(self): + self.requests_mock.request.return_value = MockResponse( + [{"name": "uploads/a.csv", "size": 10, "last_modified": "2024-01-01T00:00:00Z"}] + ) + result = self.zero_bounce_client.list_s3files(bucket="my-bucket", search="a") + self.assertEqual(result[0]["name"], "uploads/a.csv") + called_url = self.requests_mock.request.call_args[0][1] + self.assertIn("bulkapi.zerobounce.net/listS3files", called_url) + def test_validate_timeout_out_of_range(self): with self.assertRaises(ZBClientException): self.zero_bounce_client.validate("a@b.com", timeout=2) @@ -754,6 +763,14 @@ def test_add_filter_posts_to_main_api(self): data = self.requests_mock.request.call_args[1]["data"] self.assertEqual(data["rule"], "allow") + 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 + ) + url = self.requests_mock.post.call_args[0][0] + self.assertEqual(url, "https://bulkapi.zerobounce.net/validateS3file") + def test_get_list_evaluator_result_url(self): self.requests_mock.request.return_value = MockResponse({"file_status": "Complete"}) self.zero_bounce_client.get_list_evaluator_result("abc-123") From b0444f8d84f0e7ac9c1075a9263e447cc1ff9fe7 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:40:27 +0400 Subject: [PATCH 11/18] feat: S3 email finder / domain search and SFTP/FTP bulk endpoints Adds the documented endpoints the SDK was missing (https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api): - email_finder_s3file, domain_search_s3file - validate_sftpfile, score_sftpfile, email_finder_sftpfile, domain_search_sftpfile, export_sftpfile Each sends only the documented fields (file_name, has_header_row, remove_duplicates, return_url; file_id and export_path for the export). Existing S3 methods now match the docs page: - list_s3files returns the files of the documented {"success", "files"} object instead of raising "Unexpected listS3files response". - score_s3file sends return_url. - export_s3file's export_bucket is optional (account setting otherwise). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 26 ++++++ documentation.md | 26 ++++++ src/zerobouncesdk/zerobouncesdk.py | 143 ++++++++++++++++++++++++++++- tests/test_offline_api.py | 56 ++++++++++- 5 files changed, 246 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0594be..1f3f442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). - Filters (`add_filter`, `list_filters`, `delete_filter`). -- S3 bulk (`validate_s3file`, `score_s3file`, `export_s3file`, `list_s3files`). +- 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`). 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. - `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. diff --git a/README.md b/README.md index c4734f8..0419b2a 100644 --- a/README.md +++ b/README.md @@ -464,6 +464,32 @@ print(zero_bounce.list_filters()) # [{"rule": ..., "targ zero_bounce.delete_filter("block", "domain", "example.com") ``` +### Amazon S3 and SFTP/FTP files + +These run validation, scoring, the email finder or domain search on a file already in your Amazon S3 bucket or on your SFTP/FTP server, as configured in your ZeroBounce settings. See [Amazon S3 Bulk API](https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api) and [SFTP/FTP Bulk API](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api). + +```python +from zerobouncesdk import ZeroBounce, ZBException + +zero_bounce = ZeroBounce("") + +try: + files = zero_bounce.list_s3files(search="2026") # [{"Name": ..., "Size": ..., "LastModified": ...}] + name = files[0]["Name"] # full path in the bucket + + job = zero_bounce.validate_s3file(name, has_header_row=True) + # Also: score_s3file, email_finder_s3file(name, True), domain_search_s3file(name, True) + zero_bounce.export_s3file(job.file_id) # export_bucket defaults to the one in your settings + + job = zero_bounce.score_sftpfile("my_file.txt", True, return_url="https://example.com/done") + # Also: validate_sftpfile, email_finder_sftpfile, domain_search_sftpfile + zero_bounce.export_sftpfile(job.file_id, export_path="/exports") # export_path is optional +except ZBException as e: + print("ZeroBounce S3/SFTP error: " + str(e)) +``` + +`remove_duplicates` (default `True` on the API) and `return_url` are optional on every file job. + ### Inbox Placement Tester Tests use the account's inbox-test credits. diff --git a/documentation.md b/documentation.md index 12ab32c..37bbe09 100644 --- a/documentation.md +++ b/documentation.md @@ -449,6 +449,32 @@ print(zero_bounce.list_filters()) # [{"rule": ..., "targ zero_bounce.delete_filter("block", "domain", "example.com") ``` +##### Amazon S3 and SFTP/FTP files + +These run validation, scoring, the email finder or domain search on a file already in your Amazon S3 bucket or on your SFTP/FTP server, as configured in your ZeroBounce settings. See [Amazon S3 Bulk API](https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api) and [SFTP/FTP Bulk API](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api). + +```python +from zerobouncesdk import ZeroBounce, ZBException + +zero_bounce = ZeroBounce("") + +try: + files = zero_bounce.list_s3files(search="2026") # [{"Name": ..., "Size": ..., "LastModified": ...}] + name = files[0]["Name"] # full path in the bucket + + job = zero_bounce.validate_s3file(name, has_header_row=True) + # Also: score_s3file, email_finder_s3file(name, True), domain_search_s3file(name, True) + zero_bounce.export_s3file(job.file_id) # export_bucket defaults to the one in your settings + + job = zero_bounce.score_sftpfile("my_file.txt", True, return_url="https://example.com/done") + # Also: validate_sftpfile, email_finder_sftpfile, domain_search_sftpfile + zero_bounce.export_sftpfile(job.file_id, export_path="/exports") # export_path is optional +except ZBException as e: + print("ZeroBounce S3/SFTP error: " + str(e)) +``` + +`remove_duplicates` (default `True` on the API) and `return_url` are optional on every file job. + ##### Inbox Placement Tester Tests use the account's inbox-test credits. diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 372a9fa..6a302bd 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -1368,6 +1368,9 @@ def list_s3files( ) if isinstance(payload, dict) and payload.get("error"): raise ZBApiException(payload["error"]) + # Documented as {"success": true, "files": [{"Name", "Size", "LastModified"}]}. + if isinstance(payload, dict) and isinstance(payload.get("files"), list): + return payload["files"] if not isinstance(payload, list): raise ZBApiException("Unexpected listS3files response") return payload @@ -1418,6 +1421,7 @@ def score_s3file( remove_duplicates: bool = None, remove_duplicate: bool = None, email_address_column: int = None, + return_url: str = None, ) -> ZBSendFileResponse: if not file_name or not file_name.strip(): raise ZBClientException("Empty parameter: file_name") @@ -1430,18 +1434,147 @@ def score_s3file( fields["remove_duplicate"] = remove_duplicate if email_address_column is not None: fields["email_address_column"] = email_address_column + if return_url is not None: + fields["return_url"] = return_url return self._post_bulk_root_form("/scoreS3file", fields) - def export_s3file(self, file_id: str, export_bucket: str) -> ZBSendFileResponse: + def export_s3file(self, file_id: str, export_bucket: Optional[str] = None) -> ZBSendFileResponse: + """Export a validated or scored file back to Amazon S3 (POST /exportS3file). + + Without ``export_bucket`` the export bucket from your ZeroBounce settings is used. + """ if not file_id.strip(): raise ZBClientException("Empty parameter: file_id") - if not export_bucket or not export_bucket.strip(): + if export_bucket is not None and not export_bucket.strip(): raise ZBClientException("Empty parameter: export_bucket") - return self._post_bulk_root_form( - "/exportS3file", - {"file_id": file_id, "export_bucket": export_bucket}, + fields = {"file_id": file_id} + if export_bucket is not None: + fields["export_bucket"] = export_bucket + return self._post_bulk_root_form("/exportS3file", fields) + + def _bulk_root_file_job( + self, + path: str, + file_name: Optional[str], + has_header_row: bool, + remove_duplicates: Optional[bool], + return_url: Optional[str], + *, + file_name_required: bool = True, + ) -> ZBSendFileResponse: + """POST one of the documented S3 / SFTP file jobs on the bulkapi root.""" + 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") + fields = {"has_header_row": has_header_row} + if file_name is not None: + fields["file_name"] = file_name + if remove_duplicates is not None: + fields["remove_duplicates"] = remove_duplicates + if return_url is not None: + fields["return_url"] = return_url + return self._post_bulk_root_form(path, fields) + + def email_finder_s3file( + self, + file_name: str, + has_header_row: bool, + remove_duplicates: Optional[bool] = None, + return_url: Optional[str] = None, + ) -> ZBSendFileResponse: + """Run the email finder on a file in your Amazon S3 bucket (POST /emailFinderS3file). + + ``file_name`` is the full path as returned by ``list_s3files``. + """ + return self._bulk_root_file_job( + "/emailFinderS3file", file_name, has_header_row, remove_duplicates, return_url ) + def domain_search_s3file( + self, + file_name: str, + has_header_row: bool, + remove_duplicates: Optional[bool] = None, + return_url: Optional[str] = None, + ) -> ZBSendFileResponse: + """Run domain search on a file in your Amazon S3 bucket (POST /domainSearchS3file).""" + return self._bulk_root_file_job( + "/domainSearchS3file", file_name, has_header_row, remove_duplicates, return_url + ) + + # SFTP / FTP bulk (bulkapi root). The server, path and protocol come from your ZeroBounce settings. + + def validate_sftpfile( + self, + file_name: Optional[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. + """ + return self._bulk_root_file_job( + "/validateSFTPfile", + file_name, + has_header_row, + remove_duplicates, + return_url, + file_name_required=False, + ) + + def score_sftpfile( + self, + file_name: str, + has_header_row: bool, + remove_duplicates: Optional[bool] = None, + return_url: Optional[str] = None, + ) -> ZBSendFileResponse: + """Score a file from your SFTP/FTP server (POST /scoreSFTPfile). ``file_name`` is e.g. ``my_file.txt``.""" + return self._bulk_root_file_job( + "/scoreSFTPfile", file_name, has_header_row, remove_duplicates, return_url + ) + + def email_finder_sftpfile( + self, + file_name: str, + has_header_row: bool, + remove_duplicates: Optional[bool] = None, + return_url: Optional[str] = None, + ) -> ZBSendFileResponse: + """Run the email finder on a file from your SFTP/FTP server (POST /emailFinderSFTPfile).""" + return self._bulk_root_file_job( + "/emailFinderSFTPfile", file_name, has_header_row, remove_duplicates, return_url + ) + + def domain_search_sftpfile( + self, + file_name: str, + has_header_row: bool, + remove_duplicates: Optional[bool] = None, + return_url: Optional[str] = None, + ) -> ZBSendFileResponse: + """Run domain search on a file from your SFTP/FTP server (POST /domainSearchSFTPfile).""" + return self._bulk_root_file_job( + "/domainSearchSFTPfile", file_name, has_header_row, remove_duplicates, return_url + ) + + def export_sftpfile(self, file_id: str, export_path: Optional[str] = None) -> ZBSendFileResponse: + """Export a validated or scored file back to your SFTP/FTP server (POST /exportSFTPfile). + + Without ``export_path`` the export path from your ZeroBounce settings is used. + """ + if not file_id or not file_id.strip(): + raise ZBClientException("Empty parameter: file_id") + if export_path is not None and not export_path.strip(): + raise ZBClientException("Empty parameter: export_path") + fields = {"file_id": file_id} + if export_path is not None: + fields["export_path"] = export_path + return self._post_bulk_root_form("/exportSFTPfile", fields) + # Inbox Placement Tester (bulkapi /api/ipt). Uses the account's inbox-test credits. @staticmethod diff --git a/tests/test_offline_api.py b/tests/test_offline_api.py index 2e24cd0..53ee46f 100644 --- a/tests/test_offline_api.py +++ b/tests/test_offline_api.py @@ -215,7 +215,7 @@ def _success(self, recorded, key): } if path.endswith("/deletefile"): return {"success": True, "message": "File Deleted", "file_id": recorded.field("file_id"), "file_name": "emails.csv", **echo} - if path.endswith("/sendfile") or "listevaluator" in path or "send-evaluated-file" in path or path.endswith("S3file"): + if path.endswith("/sendfile") or "listevaluator" in path or "send-evaluated-file" in path or path.endswith("S3file") or path.endswith("SFTPfile"): return {"success": True, "message": "File Accepted", "file_id": "file-1", "file_name": "emails.csv", **echo} if path.endswith("/guessformat"): return { @@ -234,6 +234,9 @@ def _success(self, recorded, key): } if "/filters/" in path: return {"Message": "Filter successfully added", **echo} + if path.endswith("/listS3files") and key.startswith("s3files-"): + files = [{"Name": "uploads/a.csv", "Size": 100, "LastModified": "Mon, 08 May 2023 00:00:01 GMT"}] + return {"success": True, "files": files} if path.endswith("/listS3files"): return [{"name": "uploads/a.csv", "echo_key": key}] if "/listevaluator/" in path: @@ -333,6 +336,13 @@ def test_every_public_method_hits_the_right_endpoint(self): ("validate_s3file", lambda: self.client.validate_s3file("lists/jan.csv"), "POST", "bulkapi.zerobounce.net", "/validateS3file"), ("score_s3file", lambda: self.client.score_s3file("lists/jan.csv"), "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"), + ("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"), ("find_domain", lambda: self.client.find_domain(domain="example.com"), "GET", "api.zerobounce.net", "/v2/guessformat"), @@ -495,6 +505,40 @@ def test_inbox_placement_list_filters(self): self.assertEqual(recorded.query["page"], "2") 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) + recorded = self._only() + self.assertEqual(recorded.form, {"api_key": "client-key", "file_name": "lists/people.csv", "has_header_row": "false"}) + + self.transport.requests.clear() + self.client.domain_search_sftpfile("my_file.txt", True, remove_duplicates=False, return_url="https://hooks.example.com/done") + recorded = self._only() + self.assertEqual(recorded.form["has_header_row"], "true") + self.assertEqual(recorded.form["remove_duplicates"], "false") + 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) + + self.transport.requests.clear() + self.client.score_s3file("lists/jan.csv", has_header_row=True, return_url="https://hooks.example.com/s") + self.assertEqual(self._only().form["return_url"], "https://hooks.example.com/s") + + self.transport.requests.clear() + self.client.export_s3file("file-9") + self.assertEqual(self._only().form, {"api_key": "client-key", "file_id": "file-9"}) + + self.transport.requests.clear() + self.client.export_sftpfile("file-9", export_path="/exports") + self.assertEqual(self._only().form["export_path"], "/exports") + + def test_list_s3files_reads_the_documented_files_object(self): + files = ZeroBounce("s3files-key").list_s3files() + self.assertEqual(files[0]["Name"], "uploads/a.csv") + self.assertEqual(self.client.list_s3files()[0]["name"], "uploads/a.csv") + def test_query_values_are_percent_encoded(self): client = ZeroBounce("key with space&plus") client.validate("a b@ex.com") @@ -585,6 +629,13 @@ def test_invalid_arguments_send_no_request(self): lambda: self.client.validate_s3file(" "), lambda: self.client.export_s3file(" ", "bucket"), lambda: self.client.export_s3file("file-9", " "), + lambda: self.client.email_finder_s3file(" ", True), + 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.email_finder_sftpfile("f.txt", None), + lambda: self.client.export_sftpfile(" "), + lambda: self.client.export_sftpfile("file-9", " "), lambda: self.client.get_list_evaluator_result(" "), lambda: self.client.email_finder_file_status(" "), lambda: self.client.validate("a@b.com", callback_url="https://hooks.example.com/zb"), @@ -631,6 +682,9 @@ 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.export_sftpfile("file-9"), lambda: client.find_email_format("Ada", domain="example.com"), lambda: client.find_domain(domain="example.com"), lambda: client.guess_format("example.com"), From cb415f9ab83372644a5de2ecd6459b11764903ed Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:41:25 +0400 Subject: [PATCH 12/18] feat: add ai_agent_mailbox sub_status (RD-66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API now returns ai_agent_mailbox under both the valid and catch-all statuses. No new status was added. Adds the value to ZBValidateSubStatus following role_based_catch_all, and keeps the sub-status list in ZBValidateResponse's docstring in sync so it does not drift from the enum. Tests cover valid + ai_agent_mailbox and catch-all + ai_agent_mailbox, plus an explicit check that an unrecognised sub_status still warns and falls back to None rather than raising — that behaviour already existed via safe_enum_convert but was not covered. No sub_status_ai_agent_mailbox usage counter: it is not documented. Co-Authored-By: Claude Opus 5 --- src/zerobouncesdk/zb_validate_response.py | 2 +- src/zerobouncesdk/zb_validate_sub_status.py | 1 + tests/zero_bounce_test_case.py | 34 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/zerobouncesdk/zb_validate_response.py b/src/zerobouncesdk/zb_validate_response.py index ef8731b..56339ab 100644 --- a/src/zerobouncesdk/zb_validate_response.py +++ b/src/zerobouncesdk/zb_validate_response.py @@ -20,7 +20,7 @@ class ZBValidateResponse(ZBResponse): exception_occurred, possible_trap, role_based, global_suppression, mailbox_not_found, no_dns_entries, failed_syntax_check, possible_typo, unroutable_ip_address, leading_period_removed, does_not_accept_mail, alias_address, role_based_catch_all, disposable, toxic, alternate, mx_forward, blocked, allowed, accept_all, - role_based_accept_all, gold]""" + role_based_accept_all, gold, ai_agent_mailbox]""" account: str = None """The portion of the email address before the "@" symbol.""" diff --git a/src/zerobouncesdk/zb_validate_sub_status.py b/src/zerobouncesdk/zb_validate_sub_status.py index e66eccb..f608cce 100644 --- a/src/zerobouncesdk/zb_validate_sub_status.py +++ b/src/zerobouncesdk/zb_validate_sub_status.py @@ -35,3 +35,4 @@ class ZBValidateSubStatus(Enum): accept_all = "accept_all" role_based_accept_all = "role_based_accept_all" gold = "gold" + ai_agent_mailbox = "ai_agent_mailbox" diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index 76f5664..90ccef9 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -209,6 +209,40 @@ def test_response_sub_status_role_based_accept_all(self): self.assertEqual(response.status, ZBValidateStatus.valid) self.assertEqual(response.sub_status, ZBValidateSubStatus.role_based_accept_all) + def test_response_sub_status_ai_agent_mailbox_valid(self): + self.requests_mock.get.return_value = MockResponse({ + "address": "agent@example.com", + "status": "valid", + "sub_status": "ai_agent_mailbox", + }) + response = self.zero_bounce_client.validate("agent@example.com") + self.assertEqual(response.address, "agent@example.com") + self.assertEqual(response.status, ZBValidateStatus.valid) + self.assertEqual(response.sub_status, ZBValidateSubStatus.ai_agent_mailbox) + + def test_response_sub_status_ai_agent_mailbox_catch_all(self): + self.requests_mock.get.return_value = MockResponse({ + "address": "agent@example.com", + "status": "catch-all", + "sub_status": "ai_agent_mailbox", + }) + response = self.zero_bounce_client.validate("agent@example.com") + self.assertEqual(response.address, "agent@example.com") + self.assertEqual(response.status, ZBValidateStatus.catch_all) + self.assertEqual(response.sub_status, ZBValidateSubStatus.ai_agent_mailbox) + + def test_response_sub_status_unknown_falls_back_to_none(self): + """An unrecognised sub_status must warn and degrade, never raise.""" + self.requests_mock.get.return_value = MockResponse({ + "address": "future@example.com", + "status": "valid", + "sub_status": "some_future_sub_status", + }) + with self.assertWarns(UserWarning): + response = self.zero_bounce_client.validate("future@example.com") + self.assertEqual(response.status, ZBValidateStatus.valid) + self.assertIsNone(response.sub_status) + def test_response_sub_status_gold(self): self.requests_mock.get.return_value = MockResponse({ "address": "gold@example.com", From bc21887abe8a0cdef34b08b5830133c48dd2e3c4 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:57:31 +0400 Subject: [PATCH 13/18] fix: align the SDK with the public docs and test the edges From an endpoint-by-endpoint audit against https://www.zerobounce.net/docs/: Fixed - find_domain / guess_format raised KeyError on a lowercase "message" error. - An unknown confidence value raised ValueError; it is now None with a warning. - free_email / mx_found / catchall_domain sent as "true"/"false" strings (as the docs' samples show mx_found) read as booleans. - processed_at without fractional seconds and get_api_usage dates in the documented yyyy/mm/dd form no longer raise; other formats stay strings. - List evaluator uploads and results return ZBListEvaluatorResponse with the documented fields instead of a ZBSendFileResponse that showed success=False. Aligned with the docs - validate_s3file / score_s3file take only the documented file_name, has_header_row (required), remove_duplicates and return_url. - send_email_finder_file needs first_name_column or full_name_column. - find_email_format's first_name is optional. - Filter rule/target and Inbox Placement Tester names are checked. - use_post=True on get_credits, get_api_usage, get_activity, find_email_format and find_domain (documented form-encoded POST). - ZBGetActivityResponse.active_first_seen and the leading_period_removed / alias_address usage counts are declared. Tests (tests/test_offline_edges.py): the API key never appears in a transport error on any request path; success=false / error_message / Cloudflare / 429 / 500 / detail bodies; every optional upload field; required-argument checks; response-model edge cases; the POST variants. README examples run offline. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- CHANGELOG.md | 15 +- README.md | 22 +- documentation.md | 22 +- src/zerobouncesdk/__init__.py | 1 + src/zerobouncesdk/_zb_utils.py | 29 ++ src/zerobouncesdk/zb_find_domain_response.py | 6 +- .../zb_find_email_format_response.py | 6 +- src/zerobouncesdk/zb_get_activity_response.py | 4 + .../zb_get_api_usage_response.py | 17 +- src/zerobouncesdk/zb_guess_format_response.py | 2 +- .../zb_list_evaluator_response.py | 32 ++ src/zerobouncesdk/zb_validate_response.py | 10 +- src/zerobouncesdk/zerobouncesdk.py | 178 +++++----- tests/test_offline_api.py | 27 +- tests/test_offline_edges.py | 324 ++++++++++++++++++ tests/zero_bounce_test_case.py | 2 +- 17 files changed, 563 insertions(+), 138 deletions(-) create mode 100644 src/zerobouncesdk/zb_list_evaluator_response.py create mode 100644 tests/test_offline_edges.py diff --git a/AGENTS.md b/AGENTS.md index aa91340..92ec7f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,10 +33,10 @@ CI (`.github/workflows/sdk_ci.yml`) runs `ruff check`, `compileall`, then `pytho - **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. Integration pages count: the S3 bulk API is documented at https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api. - **One `ZeroBounce` per API key.** The class is not a singleton. Do not reuse one instance across users or requests. The offline concurrency test constructs a separate client per key. -- **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 (`first_name_column`, and the S3 email 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. +- **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`, 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`) keep JSON booleans. -- **`allow_phase_2` is validation `send_file` and `validate_s3file` only.** Scoring `send_file` and `score_s3file` do not send it. `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 their documented `file_name`, `has_header_row`, `remove_duplicates`, and `return_url`. `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. - **A file download is not JSON.** `get_file` saves a non-empty CSV body, including `text/csv` and `text/plain`. An empty body, an HTML page (status 200 or 403), and a JSON error are `ZBApiException`. Do not treat every `text/plain` body as an error. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3f442..502c8af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,15 +15,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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`). -- Filters (`add_filter`, `list_filters`, `delete_filter`). -- 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`). The export bucket and path are optional; without them your account settings are used. +- 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`). +- 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. - `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`. - `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 +- `find_email_format`'s `first_name` is optional, as documented; it is sent only when set. +- `send_email_finder_file` needs `first_name_column` or `full_name_column`, as documented, and raises `ZBClientException` without one. - A custom `base_url` must be an `https://` URL. - API requests time out after 120 seconds unless the caller passes `timeout`. - API requests do not follow redirects. @@ -36,6 +41,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `send_file`, `scoring_send_file`, bulk email finder, domain search, and S3 uploads send `has_header_row`, `remove_duplicate`, and `remove_duplicates` as `true` or `false`. `has_header_row=False` is sent. - `send_email_finder_file`, `send_domain_search_file`, and `send_list_evaluator_file` send column `0`. They previously treated `0` as empty and rejected it before the request. - `find_email_format` raises `ZBApiException` when the API returns a `Message` error, the same way `find_domain` and `guess_format` already did. +- `find_domain` and `guess_format` raise `ZBApiException` for a lowercase `message` error instead of `KeyError`. +- 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. ### Security diff --git a/README.md b/README.md index 0419b2a..f992625 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,8 @@ response = zero_bounce.score_email("valid@example.com") # GET /v2/scoring print(response.email, response.score) ``` +`get_credits`, `get_api_usage`, `get_activity`, `score_email`, `validate`, `list_filters`, `find_email_format` and `find_domain` take `use_post=True` to send the same parameters as a form-encoded `POST`, which the API accepts as well as `GET`. + ### Bulk email finder and domain search ```python @@ -452,14 +454,14 @@ 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) # dict: status, progress, invalid_percentage, ... +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 ``` ### Allowlist and blocklist filters ```python -zero_bounce.add_filter("block", "domain", "example.com") # rule: allow | block; target: email | domain | mx | tld +zero_bounce.add_filter("block", "domain", "example.com") # rule: allow | block; target: email | domain | mx | tld (checked before sending) print(zero_bounce.list_filters()) # [{"rule": ..., "target": ..., "value": ...}] zero_bounce.delete_filter("block", "domain", "example.com") ``` @@ -477,18 +479,20 @@ try: files = zero_bounce.list_s3files(search="2026") # [{"Name": ..., "Size": ..., "LastModified": ...}] name = files[0]["Name"] # full path in the bucket - job = zero_bounce.validate_s3file(name, has_header_row=True) - # Also: score_s3file, email_finder_s3file(name, True), domain_search_s3file(name, True) - zero_bounce.export_s3file(job.file_id) # export_bucket defaults to the one in your settings - - job = zero_bounce.score_sftpfile("my_file.txt", True, return_url="https://example.com/done") + 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 # Also: validate_sftpfile, email_finder_sftpfile, domain_search_sftpfile - zero_bounce.export_sftpfile(job.file_id, export_path="/exports") # export_path is optional + + # When the file is processed, export it back with its file ID (from return_url's callback, + # file_status, or the ZeroBounce app). The bucket/path default to the ones in your settings. + zero_bounce.export_s3file("") + zero_bounce.export_sftpfile("", export_path="/exports") except ZBException as e: print("ZeroBounce S3/SFTP error: " + str(e)) ``` -`remove_duplicates` (default `True` on the API) and `return_url` are optional on every file job. +`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. ### Inbox Placement Tester diff --git a/documentation.md b/documentation.md index 37bbe09..8641fa3 100644 --- a/documentation.md +++ b/documentation.md @@ -411,6 +411,8 @@ response = zero_bounce.score_email("valid@example.com") # GET /v2/scoring print(response.email, response.score) ``` +`get_credits`, `get_api_usage`, `get_activity`, `score_email`, `validate`, `list_filters`, `find_email_format` and `find_domain` take `use_post=True` to send the same parameters as a form-encoded `POST`, which the API accepts as well as `GET`. + ##### Bulk email finder and domain search ```python @@ -437,14 +439,14 @@ 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) # dict: status, progress, invalid_percentage, ... +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 ``` ##### Allowlist and blocklist filters ```python -zero_bounce.add_filter("block", "domain", "example.com") # rule: allow | block; target: email | domain | mx | tld +zero_bounce.add_filter("block", "domain", "example.com") # rule: allow | block; target: email | domain | mx | tld (checked before sending) print(zero_bounce.list_filters()) # [{"rule": ..., "target": ..., "value": ...}] zero_bounce.delete_filter("block", "domain", "example.com") ``` @@ -462,18 +464,20 @@ try: files = zero_bounce.list_s3files(search="2026") # [{"Name": ..., "Size": ..., "LastModified": ...}] name = files[0]["Name"] # full path in the bucket - job = zero_bounce.validate_s3file(name, has_header_row=True) - # Also: score_s3file, email_finder_s3file(name, True), domain_search_s3file(name, True) - zero_bounce.export_s3file(job.file_id) # export_bucket defaults to the one in your settings - - job = zero_bounce.score_sftpfile("my_file.txt", True, return_url="https://example.com/done") + 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 # Also: validate_sftpfile, email_finder_sftpfile, domain_search_sftpfile - zero_bounce.export_sftpfile(job.file_id, export_path="/exports") # export_path is optional + + # When the file is processed, export it back with its file ID (from return_url's callback, + # file_status, or the ZeroBounce app). The bucket/path default to the ones in your settings. + zero_bounce.export_s3file("") + zero_bounce.export_sftpfile("", export_path="/exports") except ZBException as e: print("ZeroBounce S3/SFTP error: " + str(e)) ``` -`remove_duplicates` (default `True` on the API) and `return_url` are optional on every file job. +`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. ##### Inbox Placement Tester diff --git a/src/zerobouncesdk/__init__.py b/src/zerobouncesdk/__init__.py index 3e1f11a..17e60ef 100644 --- a/src/zerobouncesdk/__init__.py +++ b/src/zerobouncesdk/__init__.py @@ -13,6 +13,7 @@ from .zb_validate_batch_element import ZBValidateBatchElement from .zb_validate_batch_response import ZBValidateBatchResponse, ZBValidateBatchEmail, ZBValidateBatchError from .zb_send_file_response import ZBSendFileResponse +from .zb_list_evaluator_response import ZBListEvaluatorResponse from .zb_file_status_response import ZBFileStatusResponse from .zb_get_file_response import ZBGetFileResponse from .zb_delete_file_response import ZBDeleteFileResponse diff --git a/src/zerobouncesdk/_zb_utils.py b/src/zerobouncesdk/_zb_utils.py index e7ddc4b..2a92bbd 100644 --- a/src/zerobouncesdk/_zb_utils.py +++ b/src/zerobouncesdk/_zb_utils.py @@ -1,6 +1,7 @@ """Utility functions for ZeroBounce SDK.""" import warnings +from datetime import datetime def safe_enum_convert(enum_class, value, field_name="field", lowercase=False): @@ -41,3 +42,31 @@ def safe_enum_convert(enum_class, value, field_name="field", lowercase=False): stacklevel=3 ) return None + + +def parse_bool(value): + """The API documents some flags as JSON booleans and shows others as "true"/"false" strings.""" + if isinstance(value, str): + lowered = value.strip().lower() + if lowered == "true": + return True + if lowered == "false": + return False + return value + + +def parse_datetime(value, formats, field_name="field"): + """Parse ``value`` with the first matching format; keep the original string (with a warning) if none match.""" + if value is None or not isinstance(value, str): + return value + for fmt in formats: + try: + return datetime.strptime(value.strip(), fmt) + except ValueError: + continue + warnings.warn( + f"Unexpected {field_name} format '{value}' received from API; keeping the original string.", + UserWarning, + stacklevel=3, + ) + return value diff --git a/src/zerobouncesdk/zb_find_domain_response.py b/src/zerobouncesdk/zb_find_domain_response.py index 996c47e..ecd6d5f 100644 --- a/src/zerobouncesdk/zb_find_domain_response.py +++ b/src/zerobouncesdk/zb_find_domain_response.py @@ -2,6 +2,7 @@ from . import ZBConfidence, ZBApiException from ._zb_response import ZBResponse +from ._zb_utils import safe_enum_convert from .zb_guess_format_response import ZBDomainFormat @@ -32,11 +33,10 @@ class ZBFindDomainResponse(ZBResponse): def __init__(self, data): super().__init__(data) if "Message" in data or "message" in data: - message = data.get("message", data["Message"]) + message = data.get("message") or data.get("Message") raise ZBApiException(message) - if self.confidence and isinstance(self.confidence, str): - self.confidence = ZBConfidence(self.confidence.lower()) + self.confidence = safe_enum_convert(ZBConfidence, self.confidence, "confidence", lowercase=True) self.other_domain_formats = [ ZBDomainFormat(df_data) for df_data in data.get("other_domain_formats", []) diff --git a/src/zerobouncesdk/zb_find_email_format_response.py b/src/zerobouncesdk/zb_find_email_format_response.py index 811262d..beaf3eb 100644 --- a/src/zerobouncesdk/zb_find_email_format_response.py +++ b/src/zerobouncesdk/zb_find_email_format_response.py @@ -1,5 +1,6 @@ from . import ZBApiException, ZBConfidence from ._zb_response import ZBResponse +from ._zb_utils import safe_enum_convert class ZBFindEmailFormatResponse(ZBResponse): @@ -28,6 +29,7 @@ def __init__(self, data): if "Message" in data or "message" in data: message = data.get("message", data.get("Message")) raise ZBApiException(message) - if self.email_confidence and isinstance(self.email_confidence, str): - self.email_confidence = ZBConfidence(self.email_confidence.lower()) + self.email_confidence = safe_enum_convert( + ZBConfidence, self.email_confidence, "email_confidence", lowercase=True + ) diff --git a/src/zerobouncesdk/zb_get_activity_response.py b/src/zerobouncesdk/zb_get_activity_response.py index 3d5f677..abe8019 100644 --- a/src/zerobouncesdk/zb_get_activity_response.py +++ b/src/zerobouncesdk/zb_get_activity_response.py @@ -7,3 +7,7 @@ class ZBGetActivityResponse(ZBResponse): found: bool = False active_in_days: str = None + """30, 60, 90, 180, 365 or 365+; None when nothing was found.""" + + active_first_seen: str = None + """First date activity was seen (YYYY-MM-DD); None when nothing was found.""" diff --git a/src/zerobouncesdk/zb_get_api_usage_response.py b/src/zerobouncesdk/zb_get_api_usage_response.py index 4259d22..d362fde 100644 --- a/src/zerobouncesdk/zb_get_api_usage_response.py +++ b/src/zerobouncesdk/zb_get_api_usage_response.py @@ -1,6 +1,9 @@ from datetime import date, datetime from ._zb_response import ZBResponse +from ._zb_utils import parse_datetime + +_DATE_FORMATS = ("%m/%d/%Y", "%Y/%m/%d", "%Y-%m-%d") class ZBGetApiUsageResponse(ZBResponse): @@ -111,6 +114,12 @@ class ZBGetApiUsageResponse(ZBResponse): sub_status_role_based_accept_all: int = 0 """Total number of times the API has a sub status of role_based_accept_all""" + sub_status_leading_period_removed: int = 0 + """Total number of times the API has a sub status of leading_period_removed""" + + sub_status_alias_address: int = 0 + """Total number of times the API has a sub status of alias_address""" + start_date: date = None """Start date of query""" @@ -119,7 +128,7 @@ class ZBGetApiUsageResponse(ZBResponse): def __init__(self, data): super().__init__(data) - if self.start_date is not None: - self.start_date = datetime.strptime(self.start_date, "%m/%d/%Y").date() - if self.end_date is not None: - self.end_date = datetime.strptime(self.end_date, "%m/%d/%Y").date() + # The sample shows m/d/yyyy; the field table says yyyy/mm/dd. + for name in ("start_date", "end_date"): + parsed = parse_datetime(getattr(self, name), _DATE_FORMATS, name) + setattr(self, name, parsed.date() if isinstance(parsed, datetime) else parsed) diff --git a/src/zerobouncesdk/zb_guess_format_response.py b/src/zerobouncesdk/zb_guess_format_response.py index 059f9dc..6ae477f 100644 --- a/src/zerobouncesdk/zb_guess_format_response.py +++ b/src/zerobouncesdk/zb_guess_format_response.py @@ -48,7 +48,7 @@ class ZBGuessFormatResponse(ZBResponse): def __init__(self, data): super().__init__(data) if "Message" in data or "message" in data: - message = data.get("message", data["Message"]) + message = data.get("message") or data.get("Message") raise ZBApiException(message) self.status = safe_enum_convert(ZBValidateStatus, self.status, "status", lowercase=True) diff --git a/src/zerobouncesdk/zb_list_evaluator_response.py b/src/zerobouncesdk/zb_list_evaluator_response.py new file mode 100644 index 0000000..cab26f3 --- /dev/null +++ b/src/zerobouncesdk/zb_list_evaluator_response.py @@ -0,0 +1,32 @@ +from ._zb_response import ZBResponse + + +class ZBListEvaluatorResponse(ZBResponse): + """Response for POST /listevaluator/ and GET /listevaluator/{file_id}/.""" + + file_id: str = None + + status: str = None + """``processing`` until the evaluation is done, then ``finished``.""" + + progress: int = None + """0 to 100.""" + + invalid_percentage: float = None + + catch_all_percentage: float = None + + activity_data_percentage: float = None + + abuse: bool = None + """Whether abuse addresses were found.""" + + do_not_mail: bool = None + """Whether do-not-mail addresses were found.""" + + spam_trap: bool = None + """Whether spam traps were found.""" + + total_risky_percentage: float = None + + error_message: str = None diff --git a/src/zerobouncesdk/zb_validate_response.py b/src/zerobouncesdk/zb_validate_response.py index 56339ab..6b21361 100644 --- a/src/zerobouncesdk/zb_validate_response.py +++ b/src/zerobouncesdk/zb_validate_response.py @@ -2,7 +2,7 @@ from . import ZBValidateStatus, ZBValidateSubStatus from ._zb_response import ZBResponse -from ._zb_utils import safe_enum_convert +from ._zb_utils import parse_bool, parse_datetime, safe_enum_convert class ZBValidateResponse(ZBResponse): @@ -94,5 +94,9 @@ def __init__(self, data): self.message = self.__dict__["Message"] self.status = safe_enum_convert(ZBValidateStatus, self.status, "status") self.sub_status = safe_enum_convert(ZBValidateSubStatus, self.sub_status, "sub_status") - if self.processed_at is not None: - self.processed_at = datetime.strptime(self.processed_at, "%Y-%m-%d %H:%M:%S.%f") + for flag in ("free_email", "mx_found", "catchall_domain"): + if flag in self.__dict__: + setattr(self, flag, parse_bool(self.__dict__[flag])) + self.processed_at = parse_datetime( + self.processed_at, ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"), "processed_at" + ) diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 6a302bd..a7a9fb5 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -22,6 +22,7 @@ ZBValidateBatchElement, ZBValidateBatchResponse, ZBSendFileResponse, + ZBListEvaluatorResponse, ZBFileStatusResponse, ZBGetFileResponse, ZBDeleteFileResponse, @@ -159,6 +160,8 @@ class ZeroBounce: DEFAULT_HTTP_TIMEOUT_S = 120.0 IPT_TEST_TYPES = (1, 2, 3) IPT_TEST_STATUSES = ("running", "finished") + # Documented: letters, numbers and - / \ , . & ' ( ). Spaces are accepted too. + IPT_NAME_PUNCTUATION = frozenset(" -/\\,.&'()") @staticmethod def _require_https_url(url: str) -> str: @@ -429,7 +432,13 @@ def _post_bulk_root_form(self, path: str, fields: dict) -> ZBSendFileResponse: data=data, ) - def get_credits(self): + def _get_or_post_form(self, url, response_class, params, use_post: bool): + """GET with query parameters, or the documented form-encoded POST with the same parameters.""" + if use_post: + return self._post_form(url, response_class, data=params) + return self._get(url, response_class, params=params) + + def get_credits(self, use_post: bool = False): """Tells you how many credits you have left on your account. It's simple, fast and easy to use. @@ -443,9 +452,9 @@ def get_credits(self): Returns a ZBGetCreditsResponse object if the request was successful """ - return self._get(f"{self._base_url}/getcredits", ZBGetCreditsResponse) + return self._get_or_post_form(f"{self._base_url}/getcredits", ZBGetCreditsResponse, {}, use_post) - def get_api_usage(self, start_date: date, end_date: date): + def get_api_usage(self, start_date: date, end_date: date, use_post: bool = False): """Returns the API usage between the given dates. Parameters @@ -465,16 +474,17 @@ def get_api_usage(self, start_date: date, end_date: date): Returns a ZBGetApiUsageResponse object if the request was successful """ - return self._get( + return self._get_or_post_form( f"{self._base_url}/getapiusage", ZBGetApiUsageResponse, - params={ + { "start_date": start_date.strftime("%Y-%m-%d"), "end_date": end_date.strftime("%Y-%m-%d"), }, + use_post, ) - def get_activity(self, email: str): + def get_activity(self, email: str, use_post: bool = False): """Allows you to gather insights into your subscribers' overall email engagement Parameters @@ -492,8 +502,8 @@ def get_activity(self, email: str): Returns a ZBGetActivityResponse object if the request was successful """ - return self._get( - f"{self._base_url}/activity", ZBGetActivityResponse, params={"email": email} + return self._get_or_post_form( + f"{self._base_url}/activity", ZBGetActivityResponse, {"email": email}, use_post ) def score_email(self, email: str, use_post: bool = False): @@ -1153,6 +1163,8 @@ def send_email_finder_file( ) -> ZBSendFileResponse: if domain_column is None: raise ZBClientException("Empty parameter: domain_column") + if first_name_column is None and full_name_column is None: + raise ZBClientException("Empty parameter: first_name_column or full_name_column is required") fields = {"domain_column": domain_column, "has_header_row": has_header_row} if first_name_column is not None: fields["first_name_column"] = first_name_column @@ -1179,6 +1191,8 @@ def send_email_finder_file_stream( ) -> ZBSendFileResponse: if domain_column is None: raise ZBClientException("Empty parameter: domain_column") + if first_name_column is None and full_name_column is None: + raise ZBClientException("Empty parameter: first_name_column or full_name_column is required") fields = {"domain_column": domain_column, "has_header_row": has_header_row} if first_name_column is not None: fields["first_name_column"] = first_name_column @@ -1249,7 +1263,7 @@ def domain_search_delete_file(self, file_id: str) -> ZBDeleteFileResponse: def send_list_evaluator_file( self, file_path: str, email_address_column: int - ) -> ZBSendFileResponse: + ) -> ZBListEvaluatorResponse: if email_address_column is None: raise ZBClientException("Empty parameter: email_address_column") data = { @@ -1260,7 +1274,7 @@ def send_list_evaluator_file( files = {"file": (os.path.basename(file_path), file, "text/csv")} return self._post( f"{self.LIST_EVALUATOR_BASE_URL}/", - ZBSendFileResponse, + ZBListEvaluatorResponse, data=data, files=files, ) @@ -1270,7 +1284,7 @@ def send_list_evaluator_file_stream( file_stream: BinaryIO, file_name: str, email_address_column: int, - ) -> ZBSendFileResponse: + ) -> ZBListEvaluatorResponse: if email_address_column is None: raise ZBClientException("Empty parameter: email_address_column") data = { @@ -1280,18 +1294,21 @@ def send_list_evaluator_file_stream( files = {"file": (file_name, file_stream, "text/csv")} return self._post( f"{self.LIST_EVALUATOR_BASE_URL}/", - ZBSendFileResponse, + ZBListEvaluatorResponse, data=data, files=files, ) - def get_list_evaluator_result(self, file_id: str): + def get_list_evaluator_result(self, file_id: str) -> ZBListEvaluatorResponse: + """The evaluation's progress and, once ``status`` is ``finished``, its percentages.""" if not file_id.strip(): raise ZBClientException("Empty parameter: file_id") - return self._request_json( - "GET", - f"{self.LIST_EVALUATOR_BASE_URL}/{quote(file_id, safe='')}/", - params={"api_key": self._api_key}, + return ZBListEvaluatorResponse( + self._request_json( + "GET", + f"{self.LIST_EVALUATOR_BASE_URL}/{quote(file_id, safe='')}/", + params={"api_key": self._api_key}, + ) ) def send_evaluated_file(self, file_path: str) -> ZBSendFileResponse: @@ -1307,9 +1324,20 @@ def send_evaluated_file(self, file_path: str) -> ZBSendFileResponse: # Priority 3 — custom filters (main API) - def add_filter(self, rule: str, target: str, value: str): + FILTER_RULES = ("allow", "block") + FILTER_TARGETS = ("email", "domain", "mx", "tld") + + def _check_filter(self, rule: str, target: str, value: str) -> None: if not rule or not target or not value: raise ZBClientException("Empty parameter: rule, target, and value are required") + if rule not in self.FILTER_RULES: + raise ZBClientException("Invalid parameter: rule (must be allow or block)") + if target not in self.FILTER_TARGETS: + raise ZBClientException("Invalid parameter: target (must be email, domain, mx or tld)") + + def add_filter(self, rule: str, target: str, value: str): + """Add or update an allowlist / blocklist rule (POST /filters/add).""" + self._check_filter(rule, target, value) return self._request_json( "POST", f"{self._base_url}/filters/add", @@ -1335,8 +1363,8 @@ def list_filters(self, use_post: bool = False): ) def delete_filter(self, rule: str, target: str, value: str): - if not rule or not target or not value: - raise ZBClientException("Empty parameter: rule, target, and value are required") + """Delete an allowlist / blocklist rule (POST /filters/delete).""" + self._check_filter(rule, target, value) return self._request_json( "POST", f"{self._base_url}/filters/delete", @@ -1378,65 +1406,29 @@ def list_s3files( def validate_s3file( self, file_name: str, - has_header_row: bool = None, - remove_duplicates: bool = None, - remove_duplicate: bool = None, - email_address_column: int = None, - return_url: str = None, - first_name_column: int = None, - last_name_column: int = None, - gender_column: int = None, - ip_address_column: int = None, - allow_phase_2: Optional[bool] = None, + has_header_row: bool, + remove_duplicates: Optional[bool] = None, + return_url: Optional[str] = None, ) -> ZBSendFileResponse: - if not file_name or not file_name.strip(): - raise ZBClientException("Empty parameter: file_name") - fields = {"file_name": file_name} - if has_header_row is not None: - fields["has_header_row"] = has_header_row - if remove_duplicates is not None: - fields["remove_duplicates"] = remove_duplicates - if remove_duplicate is not None: - fields["remove_duplicate"] = remove_duplicate - if email_address_column is not None: - fields["email_address_column"] = email_address_column - if return_url is not None: - fields["return_url"] = return_url - if first_name_column is not None: - fields["first_name_column"] = first_name_column - if last_name_column is not None: - fields["last_name_column"] = last_name_column - if gender_column is not None: - fields["gender_column"] = gender_column - if ip_address_column is not None: - fields["ip_address_column"] = ip_address_column - if allow_phase_2 is not None: - fields["allow_phase_2"] = "true" if allow_phase_2 else "false" - return self._post_bulk_root_form("/validateS3file", fields) + """Validate a file in your Amazon S3 bucket (POST /validateS3file). + + ``file_name`` is the full path as returned by ``list_s3files``. + """ + return self._bulk_root_file_job( + "/validateS3file", file_name, has_header_row, remove_duplicates, return_url + ) def score_s3file( self, file_name: str, - has_header_row: bool = None, - remove_duplicates: bool = None, - remove_duplicate: bool = None, - email_address_column: int = None, - return_url: str = None, + has_header_row: bool, + remove_duplicates: Optional[bool] = None, + return_url: Optional[str] = None, ) -> ZBSendFileResponse: - if not file_name or not file_name.strip(): - raise ZBClientException("Empty parameter: file_name") - fields = {"file_name": file_name} - if has_header_row is not None: - fields["has_header_row"] = has_header_row - if remove_duplicates is not None: - fields["remove_duplicates"] = remove_duplicates - if remove_duplicate is not None: - fields["remove_duplicate"] = remove_duplicate - if email_address_column is not None: - fields["email_address_column"] = email_address_column - if return_url is not None: - fields["return_url"] = return_url - return self._post_bulk_root_form("/scoreS3file", fields) + """Score a file in your Amazon S3 bucket (POST /scoreS3file).""" + return self._bulk_root_file_job( + "/scoreS3file", file_name, has_header_row, remove_duplicates, return_url + ) def export_s3file(self, file_id: str, export_bucket: Optional[str] = None) -> ZBSendFileResponse: """Export a validated or scored file back to Amazon S3 (POST /exportS3file). @@ -1636,6 +1628,10 @@ def create_inbox_placement_test( raise ZBClientException("Empty parameter: name") if len(name) > 64: raise ZBClientException("Invalid parameter: name (at most 64 characters)") + if any(not (char.isalnum() or char in self.IPT_NAME_PUNCTUATION) for char in name): + raise ZBClientException( + "Invalid parameter: name (letters, numbers, spaces and - / \\ , . & ' ( ) only)" + ) if test_type is not None and test_type not in self.IPT_TEST_TYPES: raise ZBClientException("Invalid parameter: test_type (must be 1, 2 or 3)") if test_type == 1 and (not sender_email or not sender_email.strip()): @@ -1788,18 +1784,25 @@ def guess_format( def find_email_format( - self, - first_name: str, - domain: str = '', + self, + first_name: str = '', + domain: str = '', company_name: str = '', - middle_name: str = '', - last_name: str = '' + middle_name: str = '', + last_name: str = '', + use_post: bool = False, ): + """Find a person's email address and the domain's format (GET or POST /guessformat). + + Pass exactly one of ``domain`` or ``company_name``. The name fields are optional. + """ if not domain and not company_name: raise ZBClientException("Empty parameter: domain or company_name required") if domain and company_name: raise ZBClientException("Parameter error: domain and company_name cannot be used together") - params = {"first_name": first_name} + params = {} + if first_name: + params["first_name"] = first_name if domain: params["domain"] = domain if company_name: @@ -1809,15 +1812,15 @@ def find_email_format( if last_name: params["last_name"] = last_name - return self._get( - f"{self._base_url}/guessformat", - ZBFindEmailFormatResponse, - params) + return self._get_or_post_form( + f"{self._base_url}/guessformat", ZBFindEmailFormatResponse, params, use_post + ) def find_domain( self, domain: str = '', - company_name: str = '' + company_name: str = '', + use_post: bool = False, ): if not domain and not company_name: raise ZBClientException("Empty parameter: domain xor company_name required") @@ -1828,7 +1831,6 @@ def find_domain( params["domain"] = domain if company_name: params["company_name"] = company_name - return self._get( - f"{self._base_url}/guessformat", - ZBFindDomainResponse, - params) \ No newline at end of file + return self._get_or_post_form( + f"{self._base_url}/guessformat", ZBFindDomainResponse, params, use_post + ) \ No newline at end of file diff --git a/tests/test_offline_api.py b/tests/test_offline_api.py index 53ee46f..69df3e9 100644 --- a/tests/test_offline_api.py +++ b/tests/test_offline_api.py @@ -314,8 +314,8 @@ def test_every_public_method_hits_the_right_endpoint(self): ("scoring_get_file", lambda: self.client.scoring_get_file("file-9", str(out) + ".score"), "GET", "bulkapi.zerobounce.net", "/v2/scoring/getfile"), ("delete_file", lambda: self.client.delete_file("file-9"), "GET", "bulkapi.zerobounce.net", "/v2/deletefile"), ("scoring_delete_file", lambda: self.client.scoring_delete_file("file-9"), "GET", "bulkapi.zerobounce.net", "/v2/scoring/deletefile"), - ("send_email_finder_file", lambda: self.client.send_email_finder_file(str(csv), 1), "POST", "bulkapi.zerobounce.net", "/email-finder/sendfile"), - ("send_email_finder_file_stream", lambda: self.client.send_email_finder_file_stream(BytesIO(b"example.com\n"), "f.csv", 1), "POST", "bulkapi.zerobounce.net", "/email-finder/sendfile"), + ("send_email_finder_file", lambda: self.client.send_email_finder_file(str(csv), 1, first_name_column=2), "POST", "bulkapi.zerobounce.net", "/email-finder/sendfile"), + ("send_email_finder_file_stream", lambda: self.client.send_email_finder_file_stream(BytesIO(b"example.com\n"), "f.csv", 1, full_name_column=2), "POST", "bulkapi.zerobounce.net", "/email-finder/sendfile"), ("email_finder_file_status", lambda: self.client.email_finder_file_status("file-9"), "GET", "bulkapi.zerobounce.net", "/email-finder/filestatus"), ("email_finder_get_file", lambda: self.client.email_finder_get_file("file-9", str(out) + ".finder"), "GET", "bulkapi.zerobounce.net", "/email-finder/getfile"), ("email_finder_delete_file", lambda: self.client.email_finder_delete_file("file-9"), "GET", "bulkapi.zerobounce.net", "/email-finder/deletefile"), @@ -333,8 +333,8 @@ def test_every_public_method_hits_the_right_endpoint(self): ("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"), "POST", "bulkapi.zerobounce.net", "/validateS3file"), - ("score_s3file", lambda: self.client.score_s3file("lists/jan.csv"), "POST", "bulkapi.zerobounce.net", "/scoreS3file"), + ("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"), ("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"), @@ -563,9 +563,10 @@ def test_column_zero_is_sent_and_unset_columns_are_left_out(self): self.assertEqual(recorded.form["remove_duplicate"], "false") self.transport.requests.clear() - self.client.send_email_finder_file(str(csv), 0, has_header_row=False) + self.client.send_email_finder_file(str(csv), 0, full_name_column=0, has_header_row=False) recorded = self._only() self.assertEqual(recorded.form["domain_column"], "0") + self.assertEqual(recorded.form["full_name_column"], "0") self.assertEqual(recorded.form["has_header_row"], "false") self.assertNotIn("first_name_column", recorded.form) @@ -578,13 +579,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", email_address_column=0, has_header_row=False, remove_duplicate=False, allow_phase_2=False) - recorded = self._only() - self.assertEqual(recorded.form["email_address_column"], "0") - self.assertEqual(recorded.form["has_header_row"], "false") - self.assertEqual(recorded.form["remove_duplicate"], "false") - self.assertEqual(recorded.form["allow_phase_2"], "false") - self.assertNotIn("remove_duplicates", recorded.form) + self.client.validate_s3file("a.csv", False, remove_duplicates=False) + self.assertEqual( + self._only().form, + {"api_key": "client-key", "file_name": "a.csv", "has_header_row": "false", "remove_duplicates": "false"}, + ) self.transport.requests.clear() self.client.scoring_send_file(str(csv), 1, has_header_row=True, remove_duplicate=True) @@ -626,7 +625,9 @@ 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(" "), + lambda: self.client.validate_s3file(" ", True), + 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), diff --git a/tests/test_offline_edges.py b/tests/test_offline_edges.py new file mode 100644 index 0000000..66df742 --- /dev/null +++ b/tests/test_offline_edges.py @@ -0,0 +1,324 @@ +"""Offline edge cases: transport failures, error bodies, and every optional field on the wire.""" + +import json +import tempfile +from io import BytesIO +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch + +from requests.exceptions import ConnectionError as RequestsConnectionError + +from tests import test_offline_api as offline +from zerobouncesdk import ZBApiException, ZBApiUrl, ZBClientException, ZeroBounce + +SECRET = "secret-key-123" + + +class _Offline(TestCase): + """The offline suite's fake transport, without re-running that suite's tests.""" + + def setUp(self): + self.transport = offline.FakeTransport() + patcher = patch("requests.sessions.Session.send", lambda session, request, **kw: self.transport(request, **kw)) + patcher.start() + self.addCleanup(patcher.stop) + self.client = ZeroBounce("client-key") + + def _only(self): + self.assertEqual(len(self.transport.requests), 1) + return self.transport.requests[0] + + def _csv(self): + path = Path(tempfile.mkdtemp()) / "rows.csv" + path.write_text("email,name\nada@example.com,Ada\n") + return path + + +class TransportFailureTest(TestCase): + """A failed connection must not leak the API key through the exception message.""" + + def setUp(self): + def refuse(session, request, **kwargs): + raise RequestsConnectionError(f"Max retries exceeded with url: {request.url}") + + patcher = patch("requests.sessions.Session.send", refuse) + patcher.start() + self.addCleanup(patcher.stop) + + def test_every_transport_redacts_the_key(self): + client = ZeroBounce(SECRET) + calls = [ + lambda: client.get_credits(), # _get + lambda: client.validate("a@b.com", use_post=True), # _post_form + lambda: client.add_filter("allow", "email", "a@b.com"), # _request_json + lambda: client.send_file_stream(BytesIO(b"a@b.com\n"), "a.csv", 1), # _post + lambda: client.get_file("file-9", str(Path(tempfile.mkdtemp()) / "never.csv")), # _get_file download + lambda: client.email_finder_get_file("file-9", str(Path(tempfile.mkdtemp()) / "n.csv")), # bulk root download + lambda: client.create_inbox_placement_test("Spring launch"), # JSON body + ] + for call in calls: + with self.assertRaises(RequestsConnectionError) as caught: + call() + self.assertNotIn(SECRET, str(caught.exception), call) + + # A GET carries the key in the URL that the error quotes: it is masked, not dropped. + with self.assertRaises(RequestsConnectionError) as caught: + client.get_credits() + self.assertIn("api_key=***", str(caught.exception)) + + +class ErrorBodyTest(_Offline): + def _answer(self, status, payload, content_type="application/json"): + body = payload if isinstance(payload, bytes) else json.dumps(payload).encode() + self.transport._reply = lambda recorded: offline._response(status, body, content_type) + + def test_success_false_bodies_raise_their_message(self): + cases = [ + ({"success": False, "message": ["File cannot be found."]}, "File cannot be found."), + ({"success": "False", "error_message": "File cannot be found."}, "File cannot be found."), + ({"success": "false", "message": []}, "Request failed"), + ({"success": False, "message": "Error: Error: api_key is invalid"}, "Error: api_key is invalid"), + ] + for payload, expected in cases: + self._answer(200, payload) + with self.assertRaises(ZBApiException) as caught: + self.client.file_status("file-9") + self.assertEqual(str(caught.exception), expected, payload) + + def test_cloudflare_block_raises_its_title(self): + self._answer(200, {"title": "Access denied | zerobounce.net used Cloudflare", "cloudflare_error": True}) + with self.assertRaises(ZBApiException) as caught: + self.client.get_credits() + self.assertIn("Access denied", str(caught.exception)) + + def test_rate_limit_and_server_errors_are_short(self): + for status, body, content_type in ( + (429, b"Too Many Requests", "text/plain"), + (500, b"500 Internal Server Error", "text/html"), + (400, json.dumps({"detail": [{"loc": ["body", "name"], "msg": "field required"}]}).encode(), "application/json"), + ): + self._answer(status, body, content_type) + with self.assertRaises(ZBApiException) as caught: + self.client.validate("a@b.com") + self.assertLess(len(str(caught.exception)), 300) + self.assertNotIn(""), + lambda: self.client.create_inbox_placement_test("launch;drop"), + ): + self.transport.requests.clear() + with self.assertRaises(ZBClientException): + attempt() + self.assertEqual(self.transport.requests, []) + self.client.create_inbox_placement_test("Q4 (EU) - A/B test, v1.2 & 'final' \\ ok") + self.assertEqual(self._only().json["name"], "Q4 (EU) - A/B test, v1.2 & 'final' \\ ok") + self.transport.requests.clear() + self.client.add_filter("block", "mx", "*.example.com") + self.assertEqual(self._only().form["target"], "mx") diff --git a/tests/zero_bounce_test_case.py b/tests/zero_bounce_test_case.py index 90ccef9..c09cc92 100644 --- a/tests/zero_bounce_test_case.py +++ b/tests/zero_bounce_test_case.py @@ -781,7 +781,7 @@ def test_send_email_finder_file_uses_bulk_email_finder_url(self): }) stream = BytesIO(b"domain,name\nacme.com,Jane") self.zero_bounce_client.send_email_finder_file_stream( - stream, "finder.csv", domain_column=1 + stream, "finder.csv", domain_column=1, full_name_column=2 ) url = self.requests_mock.post.call_args[0][0] self.assertEqual(url, "https://bulkapi.zerobounce.net/email-finder/sendfile") From b1babf1629bee61054691efd95de5de9968ac48a Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:05:02 +0400 Subject: [PATCH 14/18] feat: Warmup API client (ZeroBounceWarmup) Adds ZeroBounceWarmup for https://www.zerobounce.net/docs/warmup-api, one method per documented endpoint (26): sending accounts, from-address change and listing, standard and custom engagement rules, seeds, statistics, CSV downloads, the DMARC check and the SMTP test. It is its own client because the Warmup API is: a team-scoped Warmup key sent in the API-Key header (never in the URL), host https://warmup-api.zerobounce.net or the base URL ZeroBounce provides, JSON bodies, 204 / empty-200 successes (returned as None) and CSV downloads. Documented ranges are checked before sending (rule ids, status ids, custom engagement percentages, dates, search_from characters, timezone offset, email types). Errors raise ZBApiException with the new status_code attribute, so callers can back off on 429; the API key and the SMTP password are masked in every message, and redirects are not followed. Also: - Fix: importing the SDK failed on Python 3.7-3.9 (`timedelta | None` annotation on ZeroBounce.__init__). The whole suite now passes on 3.9. - Validation error bodies {"detail": [{"loc", "msg"}]} raise "field: msg". Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 + CHANGELOG.md | 4 + README.md | 36 +++ documentation.md | 36 +++ src/zerobouncesdk/__init__.py | 1 + src/zerobouncesdk/zb_exceptions.py | 6 +- src/zerobouncesdk/zb_warmup.py | 393 +++++++++++++++++++++++++++++ src/zerobouncesdk/zerobouncesdk.py | 7 +- tests/test_warmup_offline.py | 293 +++++++++++++++++++++ 9 files changed, 776 insertions(+), 2 deletions(-) create mode 100644 src/zerobouncesdk/zb_warmup.py create mode 100644 tests/test_warmup_offline.py diff --git a/AGENTS.md b/AGENTS.md index 92ec7f2..4fbabe4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,10 @@ Guidance for AI coding agents working in this repository. ## Layout - `src/zerobouncesdk/zerobouncesdk.py`: the client. `_get`, `_post`, `_post_form`, and `_request_json` are the HTTP entry points. +- `src/zerobouncesdk/zb_warmup.py`: `ZeroBounceWarmup`, the Warmup API client. It has its own host and key, authenticates with an `API-Key` header, and reuses the error helpers in `zerobouncesdk.py`. Keep Warmup methods there, not on `ZeroBounce`. - `src/zerobouncesdk/zb_*.py`: response models, enums, and options. - `tests/zero_bounce_test_case.py`: scripted unit tests. They patch `zerobouncesdk.zerobouncesdk.requests`. +- `tests/test_offline_edges.py` and `tests/test_warmup_offline.py`: more offline suites on the same principle (error bodies, redaction, every optional field; the Warmup API). - `tests/test_offline_api.py`: offline suite. It patches `requests.sessions.Session.send`, so the real `requests` client builds the URL and body and the fake answers without the network. - `tests/zero_bounce_integration_test.py`: live API tests. `pytest` does not collect this file. Run it with `python -m unittest tests.zero_bounce_integration_test` when `ZEROBOUNCE_API_KEY` is set. - `pyproject.toml`: the only version field, `version = "x.y.z"`. `setup.py` calls `setup()` and reads that file. diff --git a/CHANGELOG.md b/CHANGELOG.md index 502c8af..2c9dd5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. +- `ZBApiException.status_code`: the HTTP status, set by `ZeroBounceWarmup` so callers can back off on 429. - `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 @@ -41,7 +43,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `send_file`, `scoring_send_file`, bulk email finder, domain search, and S3 uploads send `has_header_row`, `remove_duplicate`, and `remove_duplicates` as `true` or `false`. `has_header_row=False` is sent. - `send_email_finder_file`, `send_domain_search_file`, and `send_list_evaluator_file` send column `0`. They previously treated `0` as empty and rejected it before the request. - `find_email_format` raises `ZBApiException` when the API returns a `Message` error, the same way `find_domain` and `guess_format` already did. +- 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 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 f992625..b8e7b80 100644 --- a/README.md +++ b/README.md @@ -523,6 +523,42 @@ except ZBException as e: print("ZeroBounce inbox placement error: " + str(e)) ``` +### Warmup API + +The [Warmup API](https://www.zerobounce.net/docs/warmup-api) has its own client: it uses a Warmup API key (bound to one team) in an `API-Key` header and its own host. Pass the base URL ZeroBounce gave you if it differs from the default `https://warmup-api.zerobounce.net`. + +```python +from zerobouncesdk import ZeroBounceWarmup, ZBException, ZBApiException + +warmup = ZeroBounceWarmup("") + +try: + print(warmup.get_engagement_rules()) # -1 Custom, 0 Default ... 4 Protect + warmup.add_sending_account("sender@example.com", "self_managed", engagement_rule_id=0) + warmup.add_sending_account( + "news@example.com", + "zb_managed", + email_content={"company_name": "Acme", "types_of_emails": ["newsletter"]}, + 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")) + 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")) + csv_text = warmup.download_statistics("sender@example.com", "./stats.csv") + warmup.update_sending_account("sender@example.com", engagement_rule_id=0, status_id=4, auto_enable=False) # 4 = Paused +except ZBApiException as e: + if e.status_code == 429: # rate limit, or the custom-rule 7 day cooldown + print("Try again later: " + str(e)) + else: + print("ZeroBounce warmup error: " + str(e)) +except ZBException as e: + print("ZeroBounce warmup error: " + str(e)) +``` + +Every documented endpoint has a method: sending accounts (`list_sending_accounts`, `get_sending_account`, `add_sending_account`, `update_sending_account`, `delete_sending_account`, `change_from_address`, `list_from_addresses`), engagement (`get_engagement_rules`, `set_engagement_rule`, `get_custom_engagement`, `create_custom_engagement`, `update_custom_engagement`, `delete_custom_engagement`), and statistics and utilities (`get_seeds`, `download_seeds`, `get_seed_count`, `get_progress_today`, `get_sent_today`, `get_emails_per_day`, `get_statistics`, `get_statistics_per_day`, `get_statistics_detailed`, `download_statistics`, `get_from_address_statistics`, `check_domain_dmarc`, `test_smtp_configuration`). Calls the API answers with 204 or an empty body return `None`; the two CSV downloads return the CSV text. + ### Other hosts `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: diff --git a/documentation.md b/documentation.md index 8641fa3..429bcb8 100644 --- a/documentation.md +++ b/documentation.md @@ -508,6 +508,42 @@ except ZBException as e: print("ZeroBounce inbox placement error: " + str(e)) ``` +##### Warmup API + +The [Warmup API](https://www.zerobounce.net/docs/warmup-api) has its own client: it uses a Warmup API key (bound to one team) in an `API-Key` header and its own host. Pass the base URL ZeroBounce gave you if it differs from the default `https://warmup-api.zerobounce.net`. + +```python +from zerobouncesdk import ZeroBounceWarmup, ZBException, ZBApiException + +warmup = ZeroBounceWarmup("") + +try: + print(warmup.get_engagement_rules()) # -1 Custom, 0 Default ... 4 Protect + warmup.add_sending_account("sender@example.com", "self_managed", engagement_rule_id=0) + warmup.add_sending_account( + "news@example.com", + "zb_managed", + email_content={"company_name": "Acme", "types_of_emails": ["newsletter"]}, + 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")) + 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")) + csv_text = warmup.download_statistics("sender@example.com", "./stats.csv") + warmup.update_sending_account("sender@example.com", engagement_rule_id=0, status_id=4, auto_enable=False) # 4 = Paused +except ZBApiException as e: + if e.status_code == 429: # rate limit, or the custom-rule 7 day cooldown + print("Try again later: " + str(e)) + else: + print("ZeroBounce warmup error: " + str(e)) +except ZBException as e: + print("ZeroBounce warmup error: " + str(e)) +``` + +Every documented endpoint has a method: sending accounts (`list_sending_accounts`, `get_sending_account`, `add_sending_account`, `update_sending_account`, `delete_sending_account`, `change_from_address`, `list_from_addresses`), engagement (`get_engagement_rules`, `set_engagement_rule`, `get_custom_engagement`, `create_custom_engagement`, `update_custom_engagement`, `delete_custom_engagement`), and statistics and utilities (`get_seeds`, `download_seeds`, `get_seed_count`, `get_progress_today`, `get_sent_today`, `get_emails_per_day`, `get_statistics`, `get_statistics_per_day`, `get_statistics_detailed`, `download_statistics`, `get_from_address_statistics`, `check_domain_dmarc`, `test_smtp_configuration`). Calls the API answers with 204 or an empty body return `None`; the two CSV downloads return the CSV text. + ##### Other hosts `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: diff --git a/src/zerobouncesdk/__init__.py b/src/zerobouncesdk/__init__.py index 17e60ef..03576cb 100644 --- a/src/zerobouncesdk/__init__.py +++ b/src/zerobouncesdk/__init__.py @@ -22,3 +22,4 @@ from .zb_find_domain_response import ZBFindDomainResponse from .zerobouncesdk import ZeroBounce +from .zb_warmup import ZeroBounceWarmup diff --git a/src/zerobouncesdk/zb_exceptions.py b/src/zerobouncesdk/zb_exceptions.py index baa40ec..34abf2e 100644 --- a/src/zerobouncesdk/zb_exceptions.py +++ b/src/zerobouncesdk/zb_exceptions.py @@ -3,7 +3,11 @@ class ZBException(Exception): class ZBApiException(ZBException): - pass + """The API returned an error. ``status_code`` is the HTTP status when the client knows it.""" + + def __init__(self, *args, status_code=None): + super().__init__(*args) + self.status_code = status_code class ZBClientException(ZBException): diff --git a/src/zerobouncesdk/zb_warmup.py b/src/zerobouncesdk/zb_warmup.py new file mode 100644 index 0000000..6426f9a --- /dev/null +++ b/src/zerobouncesdk/zb_warmup.py @@ -0,0 +1,393 @@ +"""Client for the ZeroBounce Warmup API (https://www.zerobounce.net/docs/warmup-api). + +The Warmup API is separate from the email validation API: it has its own host, its own team-scoped +key, and it authenticates with an ``API-Key`` header instead of an ``api_key`` parameter. Use +``ZeroBounceWarmup`` with a Warmup API key; ``ZeroBounce`` stays for the validation API key. +""" + +import os +import re +from datetime import date, timedelta +from typing import Optional, Union +from urllib.parse import quote + +import requests +from requests.exceptions import RequestException + +from .zb_exceptions import ZBApiException, ZBClientException +from .zerobouncesdk import _looks_like_html, _message_from_body, _response_text + +DateLike = Union[date, str] + +_SEARCH_FROM = re.compile(r"^[A-Za-z0-9.\-_@+]*$") +_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +class ZeroBounceWarmup: + """The ZeroBounce Warmup API. One instance per Warmup API key; the key is bound to one team.""" + + DEFAULT_BASE_URL = "https://warmup-api.zerobounce.net" + BASE_PATH = "/api/v1/warmup" + DEFAULT_HTTP_TIMEOUT_S = 120.0 + + ONBOARDING_MODELS = ("self_managed", "zb_managed") + EMAIL_TYPES = ("transactional", "newsletter", "marketing") + STANDARD_RULE_IDS = range(0, 5) # 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect + STATUS_IDS = range(0, 9) # 4 Paused, 5 Disabled + # team_id is required by the schema but always replaced with the key's team. + _ANY_TEAM_ID = 1 + + def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[timedelta] = None): + """ + Parameters + ---------- + api_key: str + Your Warmup API key (sent in the ``API-Key`` header). + base_url: str or None + The base URL ZeroBounce gave you; defaults to https://warmup-api.zerobounce.net. + timeout: timedelta or None + Request timeout; 120 seconds by default. + """ + if not api_key or not api_key.strip(): + raise ZBClientException("Empty parameter: api_key") + base = base_url or self.DEFAULT_BASE_URL + if not isinstance(base, str) or not base.lower().startswith("https://"): + raise ZBClientException("base_url must be an https:// URL") + self._api_key = api_key + self._root = base.rstrip("/") + self.BASE_PATH + self._timeout_s = self.DEFAULT_HTTP_TIMEOUT_S if timeout is None else timeout.total_seconds() + + # -- transport ---------------------------------------------------------------------------- + + def _redact(self, text, extra_secrets=()) -> str: + text = str(text) + for secret in (self._api_key, *extra_secrets): + if secret: + for form in (secret, quote(secret, safe="")): + text = text.replace(form, "***") + return text + + def _send(self, method: str, path: str, *, params=None, json=None, secrets=()): + try: + response = requests.request( + method, + f"{self._root}{path}", + params=params, + json=json, + headers={"API-Key": self._api_key, "Accept": "application/json"}, + timeout=self._timeout_s, + allow_redirects=False, + ) + except RequestException as e: + raise type(e)(self._redact(e, secrets)) from None + status = getattr(response, "status_code", 0) or 0 + if 300 <= status < 400: + raise ZBApiException( + "ZeroBounce returned a redirect, which this client does not follow.", status_code=status + ) + if status > 299: + raise ZBApiException( + self._redact(_message_from_body(_response_text(response), status), secrets), status_code=status + ) + return response + + def _json(self, method: str, path: str, *, params=None, json=None, secrets=()): + """JSON payload of a 200, or None for the documented empty successes (204, empty 200).""" + response = self._send(method, path, params=params, json=json, secrets=secrets) + body = _response_text(response) + if response.status_code == 204 or not body.strip(): + return None + content_type = response.headers.get("Content-Type", "") + if _looks_like_html(body, content_type): + raise ZBApiException(self._redact(_message_from_body(body, response.status_code))) + try: + return response.json() + except ValueError: + raise ZBApiException(self._redact(_message_from_body(body, response.status_code))) from None + + def _csv(self, path: str, params=None, download_path: Optional[str] = None) -> str: + response = self._send("GET", path, params=params) + body = _response_text(response) + content_type = response.headers.get("Content-Type", "") + if not body.strip() or _looks_like_html(body, content_type) or "application/json" in content_type.lower(): + raise ZBApiException(self._redact(_message_from_body(body, response.status_code))) + if download_path: + directory = os.path.dirname(download_path) + if directory: + os.makedirs(directory, exist_ok=True) + with open(download_path, "wb") as handle: + handle.write(response.content) + return body + + # -- argument checks ---------------------------------------------------------------------- + + @staticmethod + def _required(value, name: str) -> str: + if value is None or not str(value).strip(): + raise ZBClientException(f"Empty parameter: {name}") + return value + + @staticmethod + def _in_range(value, allowed: range, name: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value not in allowed: + raise ZBClientException(f"Invalid parameter: {name} (must be {allowed.start} to {allowed.stop - 1})") + return value + + @staticmethod + def _iso_date(value: DateLike, name: str) -> str: + if isinstance(value, date): + return value.isoformat() + if not isinstance(value, str) or not _ISO_DATE.match(value): + raise ZBClientException(f"Invalid parameter: {name} (expected YYYY-MM-DD)") + return value + + @classmethod + def _engagement_body(cls, c_from: str, reply_pct: int, important_pct: int, click_pct: int) -> dict: + cls._required(c_from, "c_from") + cls._in_range(reply_pct, range(5, 46), "reply_pct") + cls._in_range(important_pct, range(5, 41), "important_pct") + cls._in_range(click_pct, range(0, 71), "click_pct") + if click_pct > important_pct * 1.75: + raise ZBClientException("Invalid parameter: click_pct (at most important_pct x 1.75)") + return {"c_from": c_from, "reply_pct": reply_pct, "important_pct": important_pct, "click_pct": click_pct} + + # -- sending accounts --------------------------------------------------------------------- + + def list_sending_accounts(self): + """Every sending account of the key's team (GET /sending-accounts).""" + return self._json("GET", "/sending-accounts") + + def get_sending_account(self, from_address: str): + """One sending account (GET /sending-account).""" + return self._json("GET", "/sending-account", params={"from_address": self._required(from_address, "from_address")}) + + def add_sending_account( + self, + from_address: str, + onboarding_model: str, + engagement_rule_id: Optional[int] = None, + ignore_conflict: Optional[bool] = None, + email_content: Optional[dict] = None, + managed_sending: Optional[dict] = None, + ): + """Add a from-address to warmup (POST /sending-accounts). + + Parameters + ---------- + from_address: str + onboarding_model: str + ``self_managed`` (you run the mailbox; ``engagement_rule_id`` 0-4 is required) or + ``zb_managed`` (ZeroBounce provisions it; ``engagement_rule_id`` is ignored). + ignore_conflict: bool or None + Self-managed only: do not fail with 409 when the address is already registered. + email_content: dict or None + ZB managed: ``company_name``, ``company_website``, ``company_description``, + ``types_of_emails`` (transactional, newsletter, marketing) and ``example_email``. + managed_sending: dict or None + ZB managed, write-only: ``weekdays_only``, ``timezone``, ``timezone_offset`` (-11 to 14) + and ``warmup_start_date`` (YYYY-MM-DD or a date). + + For ZB managed accounts a 200 means accepted; the mailbox is provisioned afterwards. + """ + self._required(from_address, "from_address") + if onboarding_model not in self.ONBOARDING_MODELS: + raise ZBClientException("Invalid parameter: onboarding_model (must be self_managed or zb_managed)") + body = {"from_address": from_address, "onboarding_model": onboarding_model} + if onboarding_model == "self_managed": + if engagement_rule_id is None: + raise ZBClientException("Empty parameter: engagement_rule_id (required for self_managed)") + body["engagement_rule_id"] = self._in_range(engagement_rule_id, self.STANDARD_RULE_IDS, "engagement_rule_id") + if email_content is not None or managed_sending is not None: + raise ZBClientException("Invalid parameter: email_content and managed_sending are for zb_managed") + if ignore_conflict is not None: + body["ignore_conflict"] = bool(ignore_conflict) + if email_content is not None: + content = dict(email_content) + types = content.get("types_of_emails") + if types is not None and (not isinstance(types, list) or any(t not in self.EMAIL_TYPES for t in types)): + raise ZBClientException( + "Invalid parameter: types_of_emails (transactional, newsletter and/or marketing)" + ) + body["email_content"] = content + if managed_sending is not None: + sending = dict(managed_sending) + offset = sending.get("timezone_offset") + if offset is not None and (isinstance(offset, bool) or not isinstance(offset, (int, float)) or not -11 <= offset <= 14): + raise ZBClientException("Invalid parameter: timezone_offset (must be -11 to 14)") + if sending.get("warmup_start_date") is not None: + sending["warmup_start_date"] = self._iso_date(sending["warmup_start_date"], "warmup_start_date") + body["managed_sending"] = sending + return self._json("POST", "/sending-accounts", json=body) + + def update_sending_account(self, from_address: str, engagement_rule_id: int, status_id: int, auto_enable: bool): + """Change an account's engagement rule, status and auto-enable flag (PUT /sending-accounts). + + ``status_id`` is 0 to 8 (4 Paused, 5 Disabled). ``auto_enable=True`` is accepted only for an + account that is not active (Paused or Disabled). + """ + self._required(from_address, "from_address") + if not isinstance(auto_enable, bool): + raise ZBClientException("Empty parameter: auto_enable (must be True or False)") + body = { + "team_id": self._ANY_TEAM_ID, + "from_address": from_address, + "engagement_rule_id": self._in_range(engagement_rule_id, self.STANDARD_RULE_IDS, "engagement_rule_id"), + "status_id": self._in_range(status_id, self.STATUS_IDS, "status_id"), + "auto_enable": auto_enable, + } + return self._json("PUT", "/sending-accounts", json=body) + + def delete_sending_account(self, from_address: str) -> None: + """Remove a sending account (DELETE /sending-accounts). Destructive and cannot be undone.""" + self._json("DELETE", "/sending-accounts", params={"from_address": self._required(from_address, "from_address")}) + + def change_from_address(self, old_from_address: str, new_from_address: str) -> None: + """Move an account and its history to a new from-address (PUT /smtp-credentials). + + Despite the path, nothing about SMTP credentials is stored. + """ + body = { + "team_id": self._ANY_TEAM_ID, + "old_from_address": self._required(old_from_address, "old_from_address"), + "new_from_address": self._required(new_from_address, "new_from_address"), + } + self._json("PUT", "/smtp-credentials", json=body) + + def list_from_addresses( + self, search_from: Optional[str] = None, offset: Optional[int] = None, limit: Optional[int] = None + ): + """Registered from-addresses (GET /from-addresses). + + ``search_from`` takes letters, digits and ``. - _ @ +`` only. ``offset`` and ``limit`` default to + 0 and 100 on the API. + """ + params = {} + if search_from is not None: + if not _SEARCH_FROM.match(search_from): + raise ZBClientException("Invalid parameter: search_from (letters, digits and . - _ @ + only)") + params["search_from"] = search_from + for name, value in (("offset", offset), ("limit", limit)): + if value is not None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ZBClientException(f"Invalid parameter: {name} (must be a non-negative integer)") + params[name] = str(value) + return self._json("GET", "/from-addresses", params=params) + + # -- engagement rules --------------------------------------------------------------------- + + def get_engagement_rules(self): + """The rule catalogue: -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect.""" + return self._json("GET", "/engagement-rules") + + def set_engagement_rule(self, c_from: str, rule_id: int) -> None: + """Set a standard rule (0 to 4) on an address (PUT /engagement-rule).""" + self._required(c_from, "c_from") + body = {"rule_id": self._in_range(rule_id, self.STANDARD_RULE_IDS, "rule_id")} + self._json("PUT", "/engagement-rule", params={"c_from": c_from}, json=body) + + def get_custom_engagement(self, c_from: str): + """An address's custom rule, with ``can_update`` and ``remaining_seconds`` (GET /custom-engagement/{c_from}).""" + path = quote(self._required(c_from, "c_from"), safe="") + return self._json("GET", f"/custom-engagement/{path}") + + def create_custom_engagement(self, c_from: str, reply_pct: int, important_pct: int, click_pct: int): + """Create a custom rule (POST /custom-engagement). Starts a 7 day cooldown on updates. + + ``reply_pct`` 5-45, ``important_pct`` 5-40, ``click_pct`` 0-70 and at most ``important_pct`` x 1.75. + """ + return self._json("POST", "/custom-engagement", json=self._engagement_body(c_from, reply_pct, important_pct, click_pct)) + + def update_custom_engagement(self, c_from: str, reply_pct: int, important_pct: int, click_pct: int): + """Update a custom rule (PUT /custom-engagement). + + During the 7 day cooldown this raises ``ZBApiException`` with ``status_code`` 429. + """ + return self._json("PUT", "/custom-engagement", json=self._engagement_body(c_from, reply_pct, important_pct, click_pct)) + + def delete_custom_engagement(self, c_from: str, engagement_rule_id: int): + """Delete a custom rule and fall back to standard rule ``engagement_rule_id`` (1 to 4) (DELETE /custom-engagement).""" + body = { + "c_from": self._required(c_from, "c_from"), + "engagement_rule_id": self._in_range(engagement_rule_id, range(1, 5), "engagement_rule_id"), + } + return self._json("DELETE", "/custom-engagement", json=body) + + # -- statistics, seeds and utilities ------------------------------------------------------ + + def get_seeds(self): + """The seed list (GET /seeds).""" + return self._json("GET", "/seeds") + + def download_seeds(self, download_path: Optional[str] = None) -> str: + """The seed list as CSV (columns ``seed,provider``) (GET /seeds/download); also saved when ``download_path`` is set.""" + return self._csv("/seeds/download", download_path=download_path) + + def get_seed_count(self): + """Number of seeds (GET /seed-count).""" + return self._json("GET", "/seed-count") + + def get_progress_today(self): + """Today's warmup progress (GET /progress-today).""" + return self._json("GET", "/progress-today") + + def get_sent_today(self): + """Messages sent today (GET /sent-today).""" + return self._json("GET", "/sent-today") + + def get_emails_per_day(self): + """Current daily volume (GET /emails-per-day).""" + return self._json("GET", "/emails-per-day") + + def get_statistics(self, from_address: str): + """Statistics for an address (GET /statistics). An address with no history returns zeros.""" + return self._json("GET", "/statistics", params={"from_address": self._required(from_address, "from_address")}) + + def get_statistics_per_day(self, from_address: str, start_date: DateLike, end_date: DateLike): + """Daily statistics between two dates, inclusive (GET /statistics-per-day).""" + self._required(from_address, "from_address") + start = self._iso_date(start_date, "start_date") + end = self._iso_date(end_date, "end_date") + if start > end: + raise ZBClientException("Invalid parameter: start_date is later than end_date") + params = {"from_address": from_address, "start_date": start, "end_date": end} + return self._json("GET", "/statistics-per-day", params=params) + + def get_statistics_detailed(self, c_from: str): + """Detailed statistics for an address (GET /statistics-detailed).""" + return self._json("GET", "/statistics-detailed", params={"c_from": self._required(c_from, "c_from")}) + + def download_statistics(self, c_from: str, download_path: Optional[str] = None) -> str: + """An address's statistics as CSV (GET /statistics/download); also saved when ``download_path`` is set.""" + return self._csv("/statistics/download", params={"c_from": self._required(c_from, "c_from")}, download_path=download_path) + + def get_from_address_statistics(self, from_address: str): + """Statistics for one from-address (GET /from-address-statistics).""" + return self._json( + "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). + + The API names the domain parameter ``from_address``; pass a domain such as ``example.com``. + """ + params = {"from_address": self._required(domain, "domain")} + if dkim_selector is not None: + params["dkim_selector"] = self._required(dkim_selector, "dkim_selector") + return self._json("GET", "/check-domain-dmarc", params=params) + + def test_smtp_configuration(self, from_address: str, username: str, password: str, server: str, port: int): + """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``. + """ + if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: + raise ZBClientException("Invalid parameter: port (must be 1 to 65535)") + body = { + "from_address": self._required(from_address, "from_address"), + "username": self._required(username, "username"), + "password": self._required(password, "password"), + "server": self._required(server, "server"), + "port": port, + } + return self._json("POST", "/test-smtp-configuration", json=body, secrets=(password, username)) diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index a7a9fb5..579bac8 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -112,6 +112,11 @@ def _message_from_body(body: str, status_code: int) -> str: return _strip_error_prefix(value) if isinstance(value, list) and value and isinstance(value[0], str) and value[0].strip(): return _strip_error_prefix(value[0]) + # A validation error list: [{"loc": ["body", "field"], "msg": "...", ...}, ...]. + if isinstance(value, list) and value and isinstance(value[0], dict) and value[0].get("msg"): + 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"]) if len(trimmed) > 500: return "ZeroBounce returned an error (HTTP %s)." % status_code return trimmed @@ -169,7 +174,7 @@ def _require_https_url(url: str) -> str: raise ZBClientException("base_url must be an https:// URL") return url.rstrip("/") - def __init__(self, api_key: str, base_url: Optional[Union[ZBApiUrl, str]] = None, timeout: timedelta | None = None): + def __init__(self, api_key: str, base_url: Optional[Union[ZBApiUrl, str]] = None, timeout: Optional[timedelta] = None): """Initialize the ZeroBounce client. Parameters diff --git a/tests/test_warmup_offline.py b/tests/test_warmup_offline.py new file mode 100644 index 0000000..1635744 --- /dev/null +++ b/tests/test_warmup_offline.py @@ -0,0 +1,293 @@ +"""Offline Warmup API suite: a fake transport answers like https://www.zerobounce.net/docs/warmup-api.""" + +import json +import tempfile +from datetime import date, timedelta +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +from requests import Response +from requests.exceptions import ConnectionError as RequestsConnectionError + +from zerobouncesdk import ZBApiException, ZBClientException, ZeroBounceWarmup + +KEY = "warmup-key-123" +CSV = "seed,provider\nseed1@example.org,gmail\n" + + +def _response(status, body=b"", content_type="application/json", headers=None): + response = Response() + response.status_code = status + response._content = body if isinstance(body, bytes) else json.dumps(body).encode() + response.headers["Content-Type"] = content_type + response.encoding = "utf-8" + for name, value in (headers or {}).items(): + response.headers[name] = value + return response + + +class Recorded: + def __init__(self, request): + parsed = urlparse(request.url) + self.method = request.method + self.url = request.url + self.host = parsed.hostname + self.path = parsed.path + self.query = {k: v[-1] for k, v in parse_qs(parsed.query, keep_blank_values=True).items()} + self.headers = {k.lower(): v for k, v in request.headers.items()} + self.json = json.loads(request.body) if request.body else None + + +class WarmupTest(TestCase): + def setUp(self): + self.requests = [] + self.replies = {} + + def send(session, request, **kwargs): + recorded = Recorded(request) + self.requests.append(recorded) + endpoint = (recorded.method, recorded.path[len("/api/v1/warmup"):]) + reply = self.replies.get(endpoint) or self.replies.get("*") + if reply is not None: + return reply(recorded) if callable(reply) else reply + return self._documented(*endpoint) + + patcher = patch("requests.sessions.Session.send", send) + patcher.start() + self.addCleanup(patcher.stop) + self.client = ZeroBounceWarmup(KEY) + + @staticmethod + def _documented(method, path): + if (method, path) in (("DELETE", "/sending-accounts"), ("PUT", "/engagement-rule")): + return _response(204, b"", "text/plain") + if (method, path) == ("PUT", "/smtp-credentials"): + return _response(200, b"", "text/plain") + if path in ("/seeds/download", "/statistics/download"): + return _response(200, CSV.encode(), "text/csv", {"Content-Disposition": "attachment; filename=warmup_seeds.csv"}) + if path == "/seed-count": + return _response(200, 42) + if path == "/engagement-rules": + return _response(200, [{"id": -1, "name": "Custom"}, {"id": 0, "name": "Default"}]) + if (method, path) == ("DELETE", "/custom-engagement"): + return _response(200, {"status": "ok", "deleted": True}) + if path == "/test-smtp-configuration": + return _response(200, {"message": "Test email sent."}) + return _response(200, {"ok": True}) + + def _only(self): + self.assertEqual(len(self.requests), 1) + return self.requests[0] + + def test_every_endpoint_method_and_path(self): + out = Path(tempfile.mkdtemp()) + cases = [ + (lambda c: c.list_sending_accounts(), "GET", "/sending-accounts"), + (lambda c: c.get_sending_account("sender@example.com"), "GET", "/sending-account"), + (lambda c: c.add_sending_account("sender@example.com", "self_managed", engagement_rule_id=0), "POST", "/sending-accounts"), + (lambda c: c.update_sending_account("sender@example.com", 1, 1, False), "PUT", "/sending-accounts"), + (lambda c: c.delete_sending_account("sender@example.com"), "DELETE", "/sending-accounts"), + (lambda c: c.change_from_address("old@example.com", "new@example.com"), "PUT", "/smtp-credentials"), + (lambda c: c.list_from_addresses(), "GET", "/from-addresses"), + (lambda c: c.get_engagement_rules(), "GET", "/engagement-rules"), + (lambda c: c.set_engagement_rule("sender@example.com", 2), "PUT", "/engagement-rule"), + (lambda c: c.get_custom_engagement("sender@example.com"), "GET", "/custom-engagement/sender%40example.com"), + (lambda c: c.create_custom_engagement("sender@example.com", 20, 25, 25), "POST", "/custom-engagement"), + (lambda c: c.update_custom_engagement("sender@example.com", 20, 25, 25), "PUT", "/custom-engagement"), + (lambda c: c.delete_custom_engagement("sender@example.com", 1), "DELETE", "/custom-engagement"), + (lambda c: c.get_seeds(), "GET", "/seeds"), + (lambda c: c.download_seeds(), "GET", "/seeds/download"), + (lambda c: c.get_seed_count(), "GET", "/seed-count"), + (lambda c: c.get_progress_today(), "GET", "/progress-today"), + (lambda c: c.get_sent_today(), "GET", "/sent-today"), + (lambda c: c.get_emails_per_day(), "GET", "/emails-per-day"), + (lambda c: c.get_statistics("sender@example.com"), "GET", "/statistics"), + (lambda c: c.get_statistics_per_day("sender@example.com", "2026-09-01", "2026-09-14"), "GET", "/statistics-per-day"), + (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.test_smtp_configuration("sender@example.com", "sender@example.com", "pw", "smtp.example.com", 587), "POST", "/test-smtp-configuration"), + ] + self.assertEqual(len(cases), 26) + for call, method, path in cases: + self.requests.clear() + call(self.client) + recorded = self._only() + self.assertEqual((recorded.method, recorded.host), (method, "warmup-api.zerobounce.net"), path) + self.assertEqual(recorded.path, "/api/v1/warmup" + path) + self.assertEqual(recorded.headers["api-key"], KEY, path) + self.assertNotIn("api_key", recorded.query, path) + self.assertNotIn(KEY, recorded.url, path) + if recorded.json is not None: + self.assertEqual(recorded.headers["content-type"], "application/json", path) + + def test_documented_successes(self): + self.assertIsNone(self.client.delete_sending_account("sender@example.com")) # 204 + self.assertIsNone(self.client.set_engagement_rule("sender@example.com", 0)) # 204 + self.assertIsNone(self.client.change_from_address("old@example.com", "new@example.com")) # empty 200 + self.assertEqual(self.client.get_seed_count(), 42) + self.assertEqual(self.client.get_engagement_rules()[0]["name"], "Custom") + self.assertEqual(self.client.delete_custom_engagement("sender@example.com", 4), {"status": "ok", "deleted": True}) + self.assertEqual(self.client.download_seeds(), CSV) + saved = Path(tempfile.mkdtemp()) / "nested" / "warmup_seeds.csv" + self.client.download_seeds(str(saved)) + self.assertEqual(saved.read_text(), CSV) + + def test_request_bodies_and_queries(self): + self.client.add_sending_account("sender@example.com", "self_managed", engagement_rule_id=0, ignore_conflict=False) + self.assertEqual( + self._only().json, + {"from_address": "sender@example.com", "onboarding_model": "self_managed", "engagement_rule_id": 0, "ignore_conflict": False}, + ) + + self.requests.clear() + self.client.add_sending_account( + "sender@example.com", + "zb_managed", + email_content={"company_name": "Acme", "types_of_emails": ["marketing"]}, + managed_sending={"weekdays_only": True, "timezone": "Europe/Bucharest", "timezone_offset": 3.0, + "warmup_start_date": date(2026, 9, 15)}, + ) + body = self._only().json + self.assertNotIn("engagement_rule_id", body) + self.assertEqual(body["email_content"]["types_of_emails"], ["marketing"]) + self.assertEqual(body["managed_sending"]["warmup_start_date"], "2026-09-15") + + self.requests.clear() + self.client.update_sending_account("sender@example.com", 1, 4, True) + self.assertEqual( + self._only().json, + {"team_id": 1, "from_address": "sender@example.com", "engagement_rule_id": 1, "status_id": 4, "auto_enable": True}, + ) + + self.requests.clear() + self.client.change_from_address("old@example.com", "new@example.com") + self.assertEqual(self._only().json, {"team_id": 1, "old_from_address": "old@example.com", "new_from_address": "new@example.com"}) + + self.requests.clear() + self.client.set_engagement_rule("sender@example.com", 2) + recorded = self._only() + self.assertEqual((recorded.query, recorded.json), ({"c_from": "sender@example.com"}, {"rule_id": 2})) + + self.requests.clear() + self.client.delete_custom_engagement("sender@example.com", 1) + recorded = self._only() + self.assertEqual(recorded.query, {}) # the docs: query parameters here return 422 + self.assertEqual(recorded.json, {"c_from": "sender@example.com", "engagement_rule_id": 1}) + + self.requests.clear() + self.client.list_from_addresses(search_from="acme", offset=0, limit=10) + self.assertEqual(self._only().query, {"search_from": "acme", "offset": "0", "limit": "10"}) + + self.requests.clear() + self.client.get_statistics_per_day("sender@example.com", date(2026, 9, 1), "2026-09-14") + self.assertEqual( + self._only().query, + {"from_address": "sender@example.com", "start_date": "2026-09-01", "end_date": "2026-09-14"}, + ) + + 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.requests.clear() + self.client.create_custom_engagement("sender@example.com", 20, 25, 25) + self.assertEqual(self._only().json, {"c_from": "sender@example.com", "reply_pct": 20, "important_pct": 25, "click_pct": 25}) + + def test_invalid_arguments_send_nothing(self): + attempts = [ + lambda: ZeroBounceWarmup(" "), + lambda: ZeroBounceWarmup(KEY, base_url="http://warmup-api.zerobounce.net"), + lambda: self.client.get_sending_account(""), + lambda: self.client.add_sending_account("sender@example.com", "managed"), + lambda: self.client.add_sending_account("sender@example.com", "self_managed"), + lambda: self.client.add_sending_account("sender@example.com", "self_managed", engagement_rule_id=5), + lambda: self.client.add_sending_account("sender@example.com", "self_managed", engagement_rule_id=-1), + lambda: self.client.add_sending_account("sender@example.com", "self_managed", engagement_rule_id=0, email_content={}), + lambda: self.client.add_sending_account("s@example.com", "zb_managed", email_content={"types_of_emails": ["promo"]}), + lambda: self.client.add_sending_account("s@example.com", "zb_managed", managed_sending={"timezone_offset": 15}), + lambda: self.client.add_sending_account("s@example.com", "zb_managed", managed_sending={"warmup_start_date": "15/09/2026"}), + lambda: self.client.update_sending_account("s@example.com", 1, 9, False), + lambda: self.client.update_sending_account("s@example.com", 7, 1, False), + lambda: self.client.update_sending_account("s@example.com", 1, 1, None), + lambda: self.client.change_from_address("old@example.com", " "), + lambda: self.client.list_from_addresses(search_from="acme corp"), + lambda: self.client.list_from_addresses(search_from="50%"), + lambda: self.client.list_from_addresses(limit=-1), + lambda: self.client.set_engagement_rule("s@example.com", -1), + lambda: self.client.create_custom_engagement("s@example.com", 4, 25, 25), + lambda: self.client.create_custom_engagement("s@example.com", 20, 41, 25), + lambda: self.client.create_custom_engagement("s@example.com", 20, 10, 18), # 18 > 10 x 1.75 + lambda: self.client.update_custom_engagement("s@example.com", 20, 25, 71), + lambda: self.client.delete_custom_engagement("s@example.com", 0), + 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.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), + ] + for attempt in attempts: + self.requests.clear() + with self.assertRaises(ZBClientException): + attempt() + self.assertEqual(self.requests, [], attempt) + self.client.create_custom_engagement("s@example.com", 20, 10, 17) # 17 <= 17.5 + + def test_documented_errors_raise_with_status(self): + cases = [ + (401, {"error": "Missing API-Key"}, "Missing API-Key"), + (400, {"error": "engagement_rule_id does not exist"}, "engagement_rule_id does not exist"), + (404, {"detail": "Not Found"}, "Not Found"), + (409, {"error": "Sending account already exists"}, "Sending account already exists"), + (422, {"detail": [{"loc": ["body", "engagement_rule_id"], "msg": "value is not a valid integer"}]}, + "engagement_rule_id: value is not a valid integer"), + (429, {"error": "Custom engagement can be updated in 604000 seconds"}, "Custom engagement can be updated in 604000 seconds"), + (502, {"error": "Upstream unavailable"}, "Upstream unavailable"), + ] + for status, body, message in cases: + self.replies["*"] = _response(status, body) + with self.assertRaises(ZBApiException) as caught: + self.client.update_custom_engagement("s@example.com", 20, 25, 25) + self.assertEqual(str(caught.exception), message) + self.assertEqual(caught.exception.status_code, status) + + def test_redirects_html_and_csv_errors(self): + self.replies["*"] = _response(302, b"", "text/plain", {"Location": "https://evil.example/"}) + with self.assertRaises(ZBApiException) as caught: + self.client.get_seeds() + self.assertEqual(caught.exception.status_code, 302) + self.assertEqual(len(self.requests), 1) # not followed + + self.replies["*"] = _response(200, b"Access denied", "text/html") + with self.assertRaises(ZBApiException): + self.client.get_seeds() + with self.assertRaises(ZBApiException): + self.client.download_seeds() + + self.replies["*"] = _response(200, {"error": "c_from not found"}) + with self.assertRaises(ZBApiException): + self.client.download_statistics("s@example.com") + + def test_secrets_never_reach_error_messages(self): + self.replies["*"] = lambda r: _response(400, {"error": f"bad login for password {r.json['password']} key {KEY}"}) + with self.assertRaises(ZBApiException) as caught: + self.client.test_smtp_configuration("s@example.com", "user@example.com", "hunter2-secret", "smtp.example.com", 587) + self.assertNotIn("hunter2-secret", str(caught.exception)) + self.assertNotIn(KEY, str(caught.exception)) + + def refuse(session, request, **kwargs): + raise RequestsConnectionError(f"failed {request.url} with headers {dict(request.headers)}") + + with patch("requests.sessions.Session.send", refuse): + with self.assertRaises(RequestsConnectionError) as caught: + self.client.get_statistics("s@example.com") + self.assertNotIn(KEY, str(caught.exception)) + + def test_custom_base_url_and_timeout(self): + client = ZeroBounceWarmup(KEY, base_url="https://warmup.example.zerobounce.net/", timeout=timedelta(seconds=5)) + client.get_engagement_rules() + recorded = self._only() + self.assertEqual((recorded.host, recorded.path), ("warmup.example.zerobounce.net", "/api/v1/warmup/engagement-rules")) From bae3de6f2d49bd8c7e4c544685cd4940b1c18a89 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:37:01 +0400 Subject: [PATCH 15/18] fix(security): keep keys out of errors and URLs, raise dependency floors From a security review and OSV / pip-audit / bandit scans of the PR: - Network errors were re-raised redacted but still carried the original exception, with the API key in its URL or headers, as __context__. The redacted copy is now raised outside the except block (all 7 request paths, both clients). - ZeroBounce and ZeroBounceWarmup reject keys with surrounding whitespace or control characters: as a header value the HTTP library raised an error that quoted the key unredacted (e.g. a key read from a file with its newline). Redaction also masks the escaped (repr) form. - Inbox Placement Tester calls send the key in the documented Authorization: Bearer header instead of the URL. - Requests to any non-https base URL are refused before sending, covering the reassignable per-API base URL attributes. - "." and ".." are rejected as URL path IDs. - ZeroBounceWarmup masks the SMTP username/password on every error path. - Dependency floors raised to the oldest releases without OSV advisories, per Python version (requests, urllib3, idna, certifi); test extra pytest. The resolved Python 3.12 environment scans clean with osv-scanner and pip-audit. - CI pins ruff (0.17.2). New tests/test_security_offline.py checks each property; reverting the __context__ or key-format fix makes it fail. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 2 +- .github/workflows/sdk_ci.yml | 2 +- CHANGELOG.md | 7 ++ README.md | 4 +- documentation.md | 2 +- pyproject.toml | 16 +++- src/zerobouncesdk/zb_warmup.py | 19 ++-- src/zerobouncesdk/zerobouncesdk.py | 120 ++++++++++++++++++----- tests/test_offline_api.py | 9 +- tests/test_security_offline.py | 148 +++++++++++++++++++++++++++++ 10 files changed, 290 insertions(+), 39 deletions(-) create mode 100644 tests/test_security_offline.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7ac54ae..421cf2b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -88,7 +88,7 @@ jobs: - name: Lint run: | - pip install ruff + pip install ruff==0.17.2 ruff check src tests python -m compileall -q src tests diff --git a/.github/workflows/sdk_ci.yml b/.github/workflows/sdk_ci.yml index 7d4ec91..fe96704 100644 --- a/.github/workflows/sdk_ci.yml +++ b/.github/workflows/sdk_ci.yml @@ -29,7 +29,7 @@ jobs: - name: Lint run: | - pip install ruff + pip install ruff==0.17.2 ruff check src tests python -m compileall -q src tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c9dd5c..1a6f27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Dependency floors are the oldest releases without known advisories (OSV) on each Python version: `requests >= 2.33.0`, `urllib3 >= 2.8.0` and `idna >= 3.15` on Python 3.10+; `requests >= 2.32.4` and `idna >= 3.15` on 3.8 and 3.9; `requests >= 2.31.0` on 3.7; `certifi >= 2026.10.19` everywhere (it removes CA roots with compromised intermediates). Python 3.9 and older cannot install the fixed `requests` 2.33 and `urllib3` 2.7+. +- The Inbox Placement Tester methods send the key in the documented `Authorization: Bearer` header instead of the URL. - `find_email_format`'s `first_name` is optional, as documented; it is sent only when set. - `send_email_finder_file` needs `first_name_column` or `full_name_column`, as documented, and raises `ZBClientException` without one. - A custom `base_url` must be an `https://` URL. @@ -52,6 +54,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- A network error no longer keeps the original, unredacted exception (with the API key in its URL or headers) as its `__context__`. The redacted copy is raised on its own. +- `ZeroBounce` and `ZeroBounceWarmup` reject a key with surrounding whitespace or control characters (for example one read from a file with its newline), which the HTTP library would otherwise quote unredacted in its error. Redaction also covers the escaped form of a key. +- Requests to a base URL that is not `https://` are refused before sending, including the reassignable `BULK_BASE_URL`, `SCORING_BASE_URL`, `EMAIL_FINDER_BASE_URL`, `DOMAIN_SEARCH_BASE_URL`, `LIST_EVALUATOR_BASE_URL`, `INBOX_PLACEMENT_BASE_URL` and `BULK_ROOT_URL`. +- IDs placed in a URL path are rejected when they are `.` or `..`. +- `ZeroBounceWarmup.test_smtp_configuration` masks the SMTP username and password in every error message. - Error messages no longer include the raw HTML access-denied page. The live `get_file` page is about 1.4 MB and contains the API key. - Redirects are not followed, so a response cannot send the client, or the API key, to another host. diff --git a/README.md b/README.md index b8e7b80..426f925 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ More information about ZeroBounce you can find in the [official documentation](h pip install zerobouncesdk ``` +Python 3.10 or newer is recommended: on 3.9 and older, the newest `requests` and `urllib3` releases (with their security fixes) cannot be installed. + ## USAGE Import the sdk in your file: @@ -496,7 +498,7 @@ except ZBException as e: ### Inbox Placement Tester -Tests use the account's inbox-test credits. +Tests use the account's inbox-test credits. The key is sent in the `Authorization: Bearer` header. ```python from zerobouncesdk import ZeroBounce, ZBException diff --git a/documentation.md b/documentation.md index 429bcb8..b3a462e 100644 --- a/documentation.md +++ b/documentation.md @@ -481,7 +481,7 @@ except ZBException as e: ##### Inbox Placement Tester -Tests use the account's inbox-test credits. +Tests use the account's inbox-test credits. The key is sent in the `Authorization: Bearer` header. ```python from zerobouncesdk import ZeroBounce, ZBException diff --git a/pyproject.toml b/pyproject.toml index f5bc8a9..fb757c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,11 +16,23 @@ classifiers = [ "Programming Language :: Python :: 3", ] keywords = ["zero", "bounce", "sdk"] -dependencies = ["requests >= 2.22.0"] +# Floors are the oldest releases without known advisories (OSV) on each Python version. +dependencies = [ + "requests >= 2.33.0; python_version >= '3.10'", + "requests >= 2.32.4; python_version >= '3.8' and python_version < '3.10'", + "requests >= 2.31.0; python_version < '3.8'", + "urllib3 >= 2.8.0; python_version >= '3.10'", + "idna >= 3.15; python_version >= '3.8'", + "certifi >= 2026.10.19", +] requires-python = ">=3.7" [project.optional-dependencies] -test = ["pytest>=7.0.0", "pytest-cov>=4.0.0"] +test = [ + "pytest >= 9.0.3; python_version >= '3.10'", + "pytest >= 7.0.0; python_version < '3.10'", + "pytest-cov >= 4.0.0", +] [project.urls] Homepage = "https://github.com/zerobounce/zero-bounce-python-sdk-setup" diff --git a/src/zerobouncesdk/zb_warmup.py b/src/zerobouncesdk/zb_warmup.py index 6426f9a..49a3c5a 100644 --- a/src/zerobouncesdk/zb_warmup.py +++ b/src/zerobouncesdk/zb_warmup.py @@ -15,7 +15,7 @@ from requests.exceptions import RequestException from .zb_exceptions import ZBApiException, ZBClientException -from .zerobouncesdk import _looks_like_html, _message_from_body, _response_text +from .zerobouncesdk import _check_api_key, _looks_like_html, _message_from_body, _path_segment, _response_text DateLike = Union[date, str] @@ -50,6 +50,7 @@ def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Option """ if not api_key or not api_key.strip(): raise ZBClientException("Empty parameter: api_key") + _check_api_key(api_key) base = base_url or self.DEFAULT_BASE_URL if not isinstance(base, str) or not base.lower().startswith("https://"): raise ZBClientException("base_url must be an https:// URL") @@ -63,7 +64,7 @@ def _redact(self, text, extra_secrets=()) -> str: text = str(text) for secret in (self._api_key, *extra_secrets): if secret: - for form in (secret, quote(secret, safe="")): + for form in (secret, quote(secret, safe=""), repr(secret)[1:-1]): text = text.replace(form, "***") return text @@ -79,7 +80,12 @@ def _send(self, method: str, path: str, *, params=None, json=None, secrets=()): allow_redirects=False, ) except RequestException as e: - raise type(e)(self._redact(e, secrets)) from None + error = type(e)(self._redact(e, secrets)) + else: + error = None + if error is not None: + # Raised outside the except block: the original, unredacted error must not be its __context__. + raise error status = getattr(response, "status_code", 0) or 0 if 300 <= status < 400: raise ZBApiException( @@ -99,11 +105,11 @@ def _json(self, method: str, path: str, *, params=None, json=None, secrets=()): return None content_type = response.headers.get("Content-Type", "") if _looks_like_html(body, content_type): - raise ZBApiException(self._redact(_message_from_body(body, response.status_code))) + raise ZBApiException(self._redact(_message_from_body(body, response.status_code), secrets)) try: return response.json() except ValueError: - raise ZBApiException(self._redact(_message_from_body(body, response.status_code))) from None + raise ZBApiException(self._redact(_message_from_body(body, response.status_code), secrets)) from None def _csv(self, path: str, params=None, download_path: Optional[str] = None) -> str: response = self._send("GET", path, params=params) @@ -287,8 +293,7 @@ def set_engagement_rule(self, c_from: str, rule_id: int) -> None: def get_custom_engagement(self, c_from: str): """An address's custom rule, with ``can_update`` and ``remaining_seconds`` (GET /custom-engagement/{c_from}).""" - path = quote(self._required(c_from, "c_from"), safe="") - return self._json("GET", f"/custom-engagement/{path}") + return self._json("GET", f"/custom-engagement/{_path_segment(c_from, 'c_from')}") def create_custom_engagement(self, c_from: str, reply_pct: int, important_pct: int, click_pct: int): """Create a custom rule (POST /custom-engagement). Starts a 7 day cooldown on updates. diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 579bac8..5808d09 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -128,6 +128,35 @@ def _message_from_body(body: str, status_code: int) -> str: return message +def _check_api_key(api_key: str) -> None: + """Reject a key with surrounding whitespace or control characters (e.g. read from a file with its newline). + + Such a key would fail as an HTTP header value, and the HTTP library's error would quote it unredacted. + """ + if api_key != api_key.strip() or any(ord(char) < 32 or ord(char) == 127 for char in api_key): + raise ZBClientException("Invalid parameter: api_key has surrounding whitespace or control characters") + + +def _path_segment(value, name: str) -> str: + """A caller value as one URL path segment: percent-encoded, and never ``.`` or ``..``.""" + if value is None or not str(value).strip(): + raise ZBClientException(f"Empty parameter: {name}") + text = str(value).strip() + if text in (".", ".."): + raise ZBClientException(f"Invalid parameter: {name}") + return quote(text, safe="") + + +def _https_url(url: str) -> str: + """Every request carries the API key: refuse to send it anywhere but https. + + Covers the per-API base URL attributes (BULK_BASE_URL and the like), which callers can reassign. + """ + if not isinstance(url, str) or not url.lower().startswith("https://"): + raise ZBClientException("Refusing to send the API key to a URL that is not https://") + return url + + def _form_data(data): """Encode booleans as the strings ``true`` and ``false`` for form and multipart bodies. @@ -193,6 +222,7 @@ def __init__(self, api_key: str, base_url: Optional[Union[ZBApiUrl, str]] = None """ if not api_key.strip(): raise ZBClientException("Empty parameter: api_key") + _check_api_key(api_key) self._api_key = api_key # Handle base_url: enum, string, or default @@ -247,8 +277,9 @@ def _should_treat_get_file_body_as_error(body: str, content_type: str) -> bool: def _redact(self, text) -> str: """Mask the API key in text that can end up in exception messages or logs.""" text = str(text) - for secret in (self._api_key, quote_plus(self._api_key)): - text = text.replace(secret, "***") + for secret in (self._api_key, quote_plus(self._api_key), repr(self._api_key)[1:-1]): + if secret: + text = text.replace(secret, "***") return text def _read_json_payload(self, response): @@ -307,16 +338,23 @@ def _raise_if_download_error(self, response, body_str: str, content_type: str) - raise ZBApiException(self._redact(_message_from_body(body_str, status))) def _get(self, url, response_class, params=None): + _https_url(url) if not params: params = {} params["api_key"] = self._api_key try: response = requests.get(url, params=params, timeout=self._timeout_s, allow_redirects=False) except RequestException as e: - raise type(e)(self._redact(e)) from None + error = type(e)(self._redact(e)) + else: + error = None + if error is not None: + # Raised outside the except block: the original, unredacted error must not be its __context__. + raise error return response_class(self._read_json_payload(response)) def _post(self, url, response_class, data=None, json=None, files=None): + _https_url(url) try: response = requests.post( url, @@ -327,10 +365,16 @@ def _post(self, url, response_class, data=None, json=None, files=None): allow_redirects=False, ) except RequestException as e: - raise type(e)(self._redact(e)) from None + error = type(e)(self._redact(e)) + else: + error = None + if error is not None: + # Raised outside the except block: the original, unredacted error must not be its __context__. + raise error return response_class(self._read_json_payload(response)) def _post_form(self, url, response_class, data=None): + _https_url(url) if not data: data = {} data = dict(data) @@ -340,13 +384,25 @@ def _post_form(self, url, response_class, data=None): url, data=_form_data(data), timeout=self._timeout_s, allow_redirects=False ) except RequestException as e: - raise type(e)(self._redact(e)) from None + error = type(e)(self._redact(e)) + else: + error = None + if error is not None: + # Raised outside the except block: the original, unredacted error must not be its __context__. + raise error return response_class(self._read_json_payload(response)) - def _request_json(self, method: str, url: str, *, params=None, data=None, files=None, json=None): + def _request_json( + self, method: str, url: str, *, params=None, data=None, files=None, json=None, bearer: bool = False + ): + """``bearer`` sends the key as ``Authorization: Bearer`` instead of an ``api_key`` parameter.""" + _https_url(url) if params is None: params = {} - if "api_key" not in params and (data is None or "api_key" not in data): + headers = None + if bearer: + headers = {"Authorization": f"Bearer {self._api_key}"} + elif "api_key" not in params and (data is None or "api_key" not in data): params = dict(params) params["api_key"] = self._api_key try: @@ -357,11 +413,17 @@ def _request_json(self, method: str, url: str, *, params=None, data=None, files= data=_form_data(data), files=files, json=json, + headers=headers, timeout=self._timeout_s, allow_redirects=False, ) except RequestException as e: - raise type(e)(self._redact(e)) from None + error = type(e)(self._redact(e)) + else: + error = None + if error is not None: + # Raised outside the except block: the original, unredacted error must not be its __context__. + raise error return self._read_json_payload(response) def _send_bulk_root_multipart( @@ -401,13 +463,18 @@ def _bulk_root_get_file( params = {"api_key": self._api_key, "file_id": file_id} try: response = requests.get( - f"{base_url}/getfile", + _https_url(f"{base_url}/getfile"), params=params, timeout=self._timeout_s, allow_redirects=False, ) except RequestException as e: - raise type(e)(self._redact(e)) from None + error = type(e)(self._redact(e)) + else: + error = None + if error is not None: + # Raised outside the except block: the original, unredacted error must not be its __context__. + raise error body_bytes = response.content or b"" body_str = body_bytes.decode("utf-8", errors="replace") content_type = response.headers.get("Content-Type") or "" @@ -1021,13 +1088,18 @@ def _get_file( try: response = requests.get( - f"{self.SCORING_BASE_URL if scoring else self.BULK_BASE_URL}/getfile", + _https_url(f"{self.SCORING_BASE_URL if scoring else self.BULK_BASE_URL}/getfile"), params=params, timeout=self._timeout_s, allow_redirects=False, ) except RequestException as e: - raise type(e)(self._redact(e)) from None + error = type(e)(self._redact(e)) + else: + error = None + if error is not None: + # Raised outside the except block: the original, unredacted error must not be its __context__. + raise error body_bytes = response.content or b"" body_str = body_bytes.decode("utf-8", errors="replace") content_type = response.headers.get("Content-Type") or "" @@ -1311,7 +1383,7 @@ def get_list_evaluator_result(self, file_id: str) -> ZBListEvaluatorResponse: return ZBListEvaluatorResponse( self._request_json( "GET", - f"{self.LIST_EVALUATOR_BASE_URL}/{quote(file_id, safe='')}/", + f"{self.LIST_EVALUATOR_BASE_URL}/{_path_segment(file_id, 'file_id')}/", params={"api_key": self._api_key}, ) ) @@ -1572,13 +1644,12 @@ def export_sftpfile(self, file_id: str, export_path: Optional[str] = None) -> ZB fields["export_path"] = export_path return self._post_bulk_root_form("/exportSFTPfile", fields) - # Inbox Placement Tester (bulkapi /api/ipt). Uses the account's inbox-test credits. + # Inbox Placement Tester (bulkapi /api/ipt). Uses the account's inbox-test credits. The key goes in the + # documented Authorization: Bearer header, so it stays out of URLs and access logs. @staticmethod def _ipt_test_path(test_id: Union[int, str]) -> str: - if test_id is None or not str(test_id).strip(): - raise ZBClientException("Empty parameter: test_id") - return quote(str(test_id).strip(), safe="") + return _path_segment(test_id, "test_id") def get_inbox_placement_providers(self): """Seed providers available for new tests (GET /api/ipt/providers). @@ -1588,11 +1659,11 @@ def get_inbox_placement_providers(self): dict with ``emailProviders`` and ``gatewayProviders``; each has ``items`` of ``{id, name, location, seedCount, seedIds}``. """ - return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/providers") + return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/providers", bearer=True) def get_inbox_placement_test_types(self): """Supported test types and the default one (GET /api/ipt/test-types).""" - return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/test-types") + return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/test-types", bearer=True) def create_inbox_placement_test( self, @@ -1661,7 +1732,7 @@ def create_inbox_placement_test( body["providers"] = list(providers) if webhook_url is not None: body["webhookUrl"] = webhook_url - return self._request_json("POST", f"{self.INBOX_PLACEMENT_BASE_URL}/test", json=body) + return self._request_json("POST", f"{self.INBOX_PLACEMENT_BASE_URL}/test", json=body, bearer=True) def get_inbox_placement_test(self, test_id: Union[int, str]): """A test and its placement results (GET /api/ipt/test/{id}). @@ -1672,7 +1743,7 @@ def get_inbox_placement_test(self, test_id: Union[int, str]): counts, ``percentages``, ``results`` and ``seeds``. """ path = self._ipt_test_path(test_id) - return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/test/{path}") + return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/test/{path}", bearer=True) def list_inbox_placement_tests( self, @@ -1712,16 +1783,16 @@ def list_inbox_placement_tests( params["page"] = page if page_size is not None: params["page_size"] = page_size - return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/tests", params=params) + return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/tests", params=params, bearer=True) def delete_inbox_placement_test(self, test_id: Union[int, str]): """Delete a finished test (DELETE /api/ipt/test/{id}). A running test cannot be deleted.""" path = self._ipt_test_path(test_id) - return self._request_json("DELETE", f"{self.INBOX_PLACEMENT_BASE_URL}/test/{path}") + return self._request_json("DELETE", f"{self.INBOX_PLACEMENT_BASE_URL}/test/{path}", bearer=True) def get_inbox_placement_webhook(self): """The webhook URL saved on this API key (GET /api/ipt/webhook); empty when none is set.""" - return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/webhook") + return self._request_json("GET", f"{self.INBOX_PLACEMENT_BASE_URL}/webhook", bearer=True) def set_inbox_placement_webhook(self, webhook_url: str): """Save the URL finished test results are posted to (PUT /api/ipt/webhook). One per API key.""" @@ -1731,6 +1802,7 @@ def set_inbox_placement_webhook(self, webhook_url: str): "PUT", f"{self.INBOX_PLACEMENT_BASE_URL}/webhook", json={"webhookUrl": webhook_url}, + bearer=True, ) def guess_format( diff --git a/tests/test_offline_api.py b/tests/test_offline_api.py index 69df3e9..039cfb5 100644 --- a/tests/test_offline_api.py +++ b/tests/test_offline_api.py @@ -65,6 +65,9 @@ def __init__(self, method, url, body, headers): @property def api_key(self): + bearer = self.headers.get("authorization", "") + if bearer.startswith("Bearer "): + return bearer[len("Bearer "):] if "api_key" in self.query: return self.query["api_key"] if self.json and isinstance(self.json, dict) and "api_key" in self.json: @@ -457,7 +460,9 @@ def test_inbox_placement_create_sends_camel_case_json(self): self.client.create_inbox_placement_test("Spring launch") recorded = self._only() self.assertEqual(recorded.json, {"name": "Spring launch"}) - self.assertEqual(recorded.query["api_key"], "client-key") + # The documented Authorization header, so the key stays out of the URL. + self.assertEqual(recorded.headers["authorization"], "Bearer client-key") + self.assertNotIn("client-key", recorded.url) self.transport.requests.clear() self.client.create_inbox_placement_test( @@ -488,7 +493,7 @@ def test_inbox_placement_create_sends_camel_case_json(self): def test_inbox_placement_list_filters(self): self.client.list_inbox_placement_tests() - self.assertEqual(self._only().query, {"api_key": "client-key"}) + self.assertEqual(self._only().query, {}) self.transport.requests.clear() self.client.list_inbox_placement_tests( diff --git a/tests/test_security_offline.py b/tests/test_security_offline.py new file mode 100644 index 0000000..f44b9a1 --- /dev/null +++ b/tests/test_security_offline.py @@ -0,0 +1,148 @@ +"""Security properties: where keys travel, and that they never reach an exception.""" + +import json +import tempfile +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch + +from requests.exceptions import ConnectionError as RequestsConnectionError + +from tests import test_offline_api as offline +from zerobouncesdk import ZBApiException, ZBClientException, ZeroBounce, ZeroBounceWarmup + +KEY = "secret-key-123" + + +class KeyFormatTest(TestCase): + """A key read from a file keeps its newline; as a header value the HTTP library would quote it unredacted.""" + + def test_keys_with_whitespace_or_control_characters_are_rejected_without_echoing_them(self): + for bad in (KEY + "\n", KEY + "\r\n", " " + KEY, KEY + " ", KEY + "\r\nX-Evil: 1", "ab\tcd", "ab\x7fcd"): + for client in (ZeroBounce, ZeroBounceWarmup): + with self.assertRaises(ZBClientException) as caught: + client(bad) + self.assertNotIn(KEY, str(caught.exception)) + self.assertNotIn("ab", str(caught.exception).replace("api_key", "")) + + def test_inner_spaces_are_still_allowed_for_the_validation_api(self): + ZeroBounce("key with space&plus") + + +class NoContextLeakTest(TestCase): + """The redacted error is raised outside the except block, so the original is not its __context__.""" + + def setUp(self): + def refuse(session, request, **kwargs): + raise RequestsConnectionError(f"failed {request.url} {dict(request.headers)}") + + patcher = patch("requests.sessions.Session.send", refuse) + patcher.start() + self.addCleanup(patcher.stop) + + def test_transport_errors_carry_no_original(self): + client = ZeroBounce(KEY) + warmup = ZeroBounceWarmup(KEY) + out = str(Path(tempfile.mkdtemp()) / "x.csv") + calls = [ + client.get_credits, + lambda: client.validate("a@b.com", use_post=True), + lambda: client.add_filter("allow", "email", "a@b.com"), + lambda: client.send_list_evaluator_file_stream(__import__("io").BytesIO(b"a\n"), "l.csv", 1), + lambda: client.get_file("file-9", out), + lambda: client.email_finder_get_file("file-9", out), + client.get_inbox_placement_providers, + warmup.get_seeds, + lambda: warmup.test_smtp_configuration("s@example.com", "user", "hunter2-pw", "smtp.example.com", 587), + ] + for call in calls: + with self.assertRaises(RequestsConnectionError) as caught: + call() + error = caught.exception + self.assertIsNone(error.__context__, call) + self.assertIsNone(error.__cause__, call) + self.assertNotIn(KEY, str(error), call) + self.assertNotIn("hunter2-pw", str(error), call) + + +class TransportPlacementTest(TestCase): + def setUp(self): + self.transport = offline.FakeTransport() + patcher = patch("requests.sessions.Session.send", lambda session, request, **kw: self.transport(request, **kw)) + patcher.start() + self.addCleanup(patcher.stop) + + def test_inbox_placement_key_is_a_bearer_header_never_in_the_url(self): + client = ZeroBounce(KEY) + for call in ( + client.get_inbox_placement_providers, + client.get_inbox_placement_test_types, + lambda: client.create_inbox_placement_test("Spring launch"), + lambda: client.get_inbox_placement_test(42), + client.list_inbox_placement_tests, + lambda: client.delete_inbox_placement_test(42), + client.get_inbox_placement_webhook, + lambda: client.set_inbox_placement_webhook("https://hooks.example.com/ipt"), + ): + self.transport.requests.clear() + call() + (recorded,) = self.transport.requests + self.assertEqual(recorded.headers["authorization"], f"Bearer {KEY}") + self.assertNotIn(KEY, recorded.url) + self.assertNotIn("api_key", recorded.query) + + def test_non_https_base_urls_never_receive_the_key(self): + csv = Path(tempfile.mkdtemp()) / "rows.csv" + csv.write_text("email\na@b.com\n") + attempts = [ + ("BULK_BASE_URL", lambda c: c.file_status("file-9")), + ("BULK_BASE_URL", lambda c: c.get_file("file-9", str(csv) + ".out")), + ("SCORING_BASE_URL", lambda c: c.scoring_send_file(str(csv), 1)), + ("EMAIL_FINDER_BASE_URL", lambda c: c.email_finder_get_file("file-9", str(csv) + ".f")), + ("DOMAIN_SEARCH_BASE_URL", lambda c: c.domain_search_file_status("file-9")), + ("LIST_EVALUATOR_BASE_URL", lambda c: c.get_list_evaluator_result("file-9")), + ("INBOX_PLACEMENT_BASE_URL", lambda c: c.get_inbox_placement_providers()), + ("BULK_ROOT_URL", lambda c: c.list_s3files()), + ] + for attribute, call in attempts: + client = ZeroBounce(KEY) + setattr(client, attribute, "http://bulkapi.zerobounce.net/v2") + self.transport.requests.clear() + with self.assertRaises(ZBClientException, msg=attribute): + call(client) + self.assertEqual(self.transport.requests, [], attribute) + + def test_dot_segments_are_rejected(self): + client = ZeroBounce(KEY) + for call in ( + lambda: client.get_list_evaluator_result(".."), + lambda: client.get_inbox_placement_test("."), + lambda: client.delete_inbox_placement_test(".."), + lambda: ZeroBounceWarmup(KEY).get_custom_engagement(".."), + ): + self.transport.requests.clear() + with self.assertRaises(ZBClientException): + call() + self.assertEqual(self.transport.requests, []) + + +class WarmupBodyRedactionTest(TestCase): + def test_smtp_secrets_are_masked_in_every_error_path(self): + warmup = ZeroBounceWarmup(KEY) + bodies = [ + (200, b"login failed for user@example.com / hunter2-pw", "text/html"), + (200, b"login failed for user@example.com with hunter2-pw", "text/plain"), + (400, json.dumps({"error": "bad password hunter2-pw for user@example.com"}).encode(), "application/json"), + ] + for status, body, content_type in bodies: + with patch( + "requests.sessions.Session.send", + lambda session, request, **kw: offline._response(status, body, content_type), + ): + try: + warmup.test_smtp_configuration("s@example.com", "user@example.com", "hunter2-pw", "smtp.example.com", 587) + except ZBApiException as error: + self.assertNotIn("hunter2-pw", str(error)) + self.assertNotIn("user@example.com", str(error)) + else: + self.fail(f"no error for {status} {content_type}") From 8c494e5b56e91ae0a3c84e0059336f5e83cd50fa Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:46:04 +0400 Subject: [PATCH 16/18] feat: opt-in streamed downloads, regional bulk-host warning, workflow hardening - get_file, scoring_get_file, email_finder_get_file and domain_search_get_file take stream=True: the result is written in chunks to a temporary file next to download_path and moved into place when complete, so a large file never has to fit in memory and a failed download leaves no partial file (an existing file is kept). The first 64 KB are checked for an error body with the same rules as before. Without the flag nothing changes. - With an EU or US base_url, a request to bulkapi.zerobounce.net warns once per client (UserWarning): ZeroBounce documents no regional bulk host (none exists in DNS either), so bulk, scoring/finder files, list evaluator, S3/SFTP and Inbox Placement Tester data goes to the global host unless the *_BASE_URL attributes are set. - publish.yml: the tag input reaches the two remaining run: steps through env instead of being expanded into the script. - dependabot-auto-merge.yml: only patch and minor updates (dependabot/fetch-metadata, pinned) are approved and auto-merged; a major update gets a comment and waits for a person. The weekly job only re-queues PRs the workflow already approved. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/dependabot-auto-merge.yml | 33 +++- .github/workflows/publish.yml | 6 +- AGENTS.md | 1 + CHANGELOG.md | 2 + README.md | 4 +- documentation.md | 4 +- src/zerobouncesdk/zerobouncesdk.py | 174 ++++++++++++++------ tests/test_streaming_and_regions_offline.py | 145 ++++++++++++++++ 8 files changed, 311 insertions(+), 58 deletions(-) create mode 100644 tests/test_streaming_and_regions_offline.py diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 138a59d..0a317ae 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -1,5 +1,9 @@ name: Dependabot security auto-merge +# Dependabot here opens security updates only (.github/dependabot.yml). Patch and minor updates are +# approved and merged once checks pass. A major update gets a comment and is left for a person to +# review and merge, because it can change behaviour even when the tests pass. + on: pull_request: types: [opened, synchronize, reopened] @@ -18,14 +22,26 @@ jobs: github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' steps: - - name: Approve Dependabot pull request - run: gh pr review --approve "$PR_URL" + - name: Read the update type + id: metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Approve and auto-merge a patch or minor update + if: steps.metadata.outputs.update-type != 'version-update:semver-major' + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --merge "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Enable auto-merge when checks pass - run: gh pr merge --auto --merge "$PR_URL" + - name: Leave a major update for review + if: steps.metadata.outputs.update-type == 'version-update:semver-major' + run: >- + gh pr comment "$PR_URL" --body "Major version update: not auto-merged. + Review the changelog and merge by hand." env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -34,16 +50,17 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'schedule' steps: - - name: Approve and queue merge for open Dependabot PRs + - name: Re-queue Dependabot pull requests already cleared for auto-merge + # Only PRs the job above approved (patch or minor); majors are never approved by a workflow. run: | set -euo pipefail - mapfile -t urls < <(gh pr list --author app/dependabot --state open --json url --jq '.[].url') + mapfile -t urls < <(gh pr list --repo "$GITHUB_REPOSITORY" --author app/dependabot --state open \ + --search "review:approved" --json url --jq '.[].url') if [ "${#urls[@]}" -eq 0 ]; then - echo "No open Dependabot pull requests." + echo "No approved Dependabot pull requests to queue." exit 0 fi for url in "${urls[@]}"; do - gh pr review --approve "$url" || true gh pr merge --auto --merge "$url" || true done env: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 421cf2b..b87c849 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,8 +36,10 @@ jobs: persist-credentials: false - name: Verify tag and package version + # Passed through env, never pasted into the script: an input expanded in run: is shell code. + env: + TAG: ${{ github.event.inputs.tag }} run: | - TAG="${{ github.event.inputs.tag }}" if [ -z "$TAG" ]; then echo "Error: Tag is required." exit 1 @@ -147,8 +149,8 @@ jobs: - name: Create release if missing env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.inputs.tag }} run: | - TAG="${{ github.event.inputs.tag }}" if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "Release $TAG already exists, skipping." else diff --git a/AGENTS.md b/AGENTS.md index 4fbabe4..a5b4fd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ CI (`.github/workflows/sdk_ci.yml`) runs `ruff check`, `compileall`, then `pytho - **`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. - **`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`). - **A file download is not JSON.** `get_file` saves a non-empty CSV body, including `text/csv` and `text/plain`. An empty body, an HTML page (status 200 or 403), and a JSON error are `ZBApiException`. Do not treat every `text/plain` body as an error. - **Do not follow redirects.** Every `requests` call passes `allow_redirects=False`. A 3xx response is a `ZBApiException`. A redirect must not be fetched, because the `Location` could be another host and the next request can carry the API key. - **Do not commit `.env`.** It is gitignored and may hold a real key. Tests must not call the live API. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a6f27d..eea0b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. +- `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. - `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. diff --git a/README.md b/README.md index 426f925..967fe65 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,8 @@ local_download_path = "./dwnld_file.csv" # The path where the file will be do try: response = zero_bounce.get_file(file_id, local_download_path) + # Large result files: stream=True writes the file in chunks instead of holding it in memory first + # response = zero_bounce.get_file(file_id, local_download_path, stream=True) print("ZeroBounce get_file response: " + str(response)) except ZBException as e: print("ZeroBounce get_file error: " + str(e)) @@ -563,7 +565,7 @@ Every documented endpoint has a method: sending accounts (`list_sending_accounts ### Other hosts -`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: +`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 zero_bounce = ZeroBounce("", base_url="https://api-eu.zerobounce.net/v2") diff --git a/documentation.md b/documentation.md index b3a462e..8362dfb 100644 --- a/documentation.md +++ b/documentation.md @@ -271,6 +271,8 @@ local_download_path = "./dwnld_file.csv" # The path where the file will be do try: response = zero_bounce.get_file(file_id, local_download_path) + # Large result files: stream=True writes the file in chunks instead of holding it in memory first + # response = zero_bounce.get_file(file_id, local_download_path, stream=True) print("ZeroBounce get_file response: " + str(response)) except ZBException as e: print("ZeroBounce get_file error: " + str(e)) @@ -546,7 +548,7 @@ Every documented endpoint has a method: sending accounts (`list_sending_accounts ##### Other hosts -`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: +`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 zero_bounce = ZeroBounce("", base_url="https://api-eu.zerobounce.net/v2") diff --git a/src/zerobouncesdk/zerobouncesdk.py b/src/zerobouncesdk/zerobouncesdk.py index 5808d09..a75b757 100644 --- a/src/zerobouncesdk/zerobouncesdk.py +++ b/src/zerobouncesdk/zerobouncesdk.py @@ -1,10 +1,12 @@ +import contextlib import json import re +import tempfile import warnings from datetime import date, timedelta import os from typing import BinaryIO, List, Optional, Union -from urllib.parse import quote, quote_plus +from urllib.parse import quote, quote_plus, urlsplit import requests from requests.exceptions import RequestException @@ -238,10 +240,31 @@ def __init__(self, api_key: str, base_url: Optional[Union[ZBApiUrl, str]] = None # Remove trailing slash if present to maintain consistent URL construction self._base_url = self._base_url.rstrip('/') + host = urlsplit(self._base_url).hostname or "" + self._region = {"api-eu.zerobounce.net": "EU", "api-us.zerobounce.net": "US"}.get(host.lower()) + self._bulk_region_warned = False + self._timeout_s: float = ( self.DEFAULT_HTTP_TIMEOUT_S if timeout is None else timeout.total_seconds() ) + _BULK_HOST = "bulkapi.zerobounce.net" + + def _checked_url(self, url: str) -> str: + """``url`` if it is https; warn once when a regional base_url meets the global bulk host.""" + _https_url(url) + if not self._bulk_region_warned and self._region and urlsplit(url).hostname == self._BULK_HOST: + self._bulk_region_warned = True + warnings.warn( + f"base_url is the {self._region} API, but this request goes to {self._BULK_HOST}: ZeroBounce " + "documents no regional host for bulk files, scoring files, email finder and domain search " + "files, the list evaluator, S3/SFTP or the Inbox Placement Tester. Set the matching *_BASE_URL " + "attributes if ZeroBounce gave you regional ones.", + UserWarning, + stacklevel=4, + ) + return url + @staticmethod def get_file_json_indicates_error(body: str) -> bool: """Whether a getfile response body looks like a JSON error payload (including HTTP 200).""" @@ -338,7 +361,7 @@ def _raise_if_download_error(self, response, body_str: str, content_type: str) - raise ZBApiException(self._redact(_message_from_body(body_str, status))) def _get(self, url, response_class, params=None): - _https_url(url) + self._checked_url(url) if not params: params = {} params["api_key"] = self._api_key @@ -354,7 +377,7 @@ def _get(self, url, response_class, params=None): return response_class(self._read_json_payload(response)) def _post(self, url, response_class, data=None, json=None, files=None): - _https_url(url) + self._checked_url(url) try: response = requests.post( url, @@ -374,7 +397,7 @@ def _post(self, url, response_class, data=None, json=None, files=None): return response_class(self._read_json_payload(response)) def _post_form(self, url, response_class, data=None): - _https_url(url) + self._checked_url(url) if not data: data = {} data = dict(data) @@ -396,7 +419,7 @@ def _request_json( self, method: str, url: str, *, params=None, data=None, files=None, json=None, bearer: bool = False ): """``bearer`` sends the key as ``Authorization: Bearer`` instead of an ``api_key`` parameter.""" - _https_url(url) + self._checked_url(url) if params is None: params = {} headers = None @@ -457,16 +480,32 @@ def _bulk_root_get_file( base_url: str, file_id: str, download_path: str, + stream: bool = False, ) -> ZBGetFileResponse: if not file_id.strip(): raise ZBClientException("Empty parameter: file_id") params = {"api_key": self._api_key, "file_id": file_id} + return self._download(f"{base_url}/getfile", params, download_path, stream) + + # Streamed downloads look at this much of the body before deciding it is a file, not an error page. + _DOWNLOAD_SNIFF_BYTES = 64 * 1024 + # An error body is short; reading more than this to show its message would defeat streaming. + _DOWNLOAD_MAX_ERROR_BYTES = 2 * 1024 * 1024 + + def _download(self, url: str, params: dict, download_path: str, stream: bool = False) -> ZBGetFileResponse: + """GET a result file and save it at ``download_path``. + + ``stream=True`` writes it in chunks to a temporary file next to ``download_path`` and moves it into + place when complete, so a large file never has to fit in memory and a failed download leaves nothing + at ``download_path``. + """ try: response = requests.get( - _https_url(f"{base_url}/getfile"), + self._checked_url(url), params=params, timeout=self._timeout_s, allow_redirects=False, + **({"stream": True} if stream else {}), ) except RequestException as e: error = type(e)(self._redact(e)) @@ -475,17 +514,71 @@ def _bulk_root_get_file( if error is not None: # Raised outside the except block: the original, unredacted error must not be its __context__. raise error - body_bytes = response.content or b"" - body_str = body_bytes.decode("utf-8", errors="replace") - content_type = response.headers.get("Content-Type") or "" - self._raise_if_download_error(response, body_str, content_type) + dirname = os.path.dirname(download_path) - if dirname: - os.makedirs(dirname, exist_ok=True) - with open(download_path, "wb") as f: - f.write(body_bytes) + if not stream: + body_bytes = response.content or b"" + body_str = body_bytes.decode("utf-8", errors="replace") + content_type = response.headers.get("Content-Type") or "" + self._raise_if_download_error(response, body_str, content_type) + if dirname: + os.makedirs(dirname, exist_ok=True) + with open(download_path, "wb") as f: + f.write(body_bytes) + return ZBGetFileResponse({"local_file_path": download_path}) + + try: + self._save_streamed(response, download_path, dirname) + finally: + response.close() return ZBGetFileResponse({"local_file_path": download_path}) + def _save_streamed(self, response, download_path: str, dirname: str) -> None: + content_type = response.headers.get("Content-Type") or "" + try: + chunks = response.iter_content(chunk_size=64 * 1024) + head = b"" + for chunk in chunks: + head += chunk + if len(head) >= self._DOWNLOAD_SNIFF_BYTES: + break + head_str = head.decode("utf-8", errors="replace") + might_be_error = ( + not 200 <= response.status_code < 300 + or not head.strip() + or "application/json" in content_type.lower() + or _looks_like_html(head_str, content_type) + or head.lstrip()[:1] in (b"{", b"[") + ) + if might_be_error: + for chunk in chunks: + head += chunk + if len(head) >= self._DOWNLOAD_MAX_ERROR_BYTES: + break + self._raise_if_download_error(response, head.decode("utf-8", errors="replace"), content_type) + + if dirname: + os.makedirs(dirname, exist_ok=True) + fd, partial = tempfile.mkstemp(dir=dirname or ".", prefix=".zb-download-", suffix=".part") + try: + with os.fdopen(fd, "wb") as out: + out.write(head) + for chunk in chunks: + if chunk: + out.write(chunk) + os.replace(partial, download_path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(partial) + raise + except RequestException as e: + error = type(e)(self._redact(e)) + else: + error = None + if error is not None: + # A connection lost mid-download: redacted, and raised outside the except block. + raise error + def _bulk_root_delete_file(self, base_url: str, file_id: str) -> ZBDeleteFileResponse: if not file_id.strip(): raise ZBClientException("Empty parameter: file_id") @@ -1073,6 +1166,7 @@ def _get_file( file_id: str, download_path: str, options: Optional[ZBGetFileOptions] = None, + stream: bool = False, ): if not file_id.strip(): raise ZBClientException("Empty parameter: file_id") @@ -1086,38 +1180,15 @@ def _get_file( if not scoring and options.activity_data is not None: params["activity_data"] = "true" if options.activity_data else "false" - try: - response = requests.get( - _https_url(f"{self.SCORING_BASE_URL if scoring else self.BULK_BASE_URL}/getfile"), - params=params, - timeout=self._timeout_s, - allow_redirects=False, - ) - except RequestException as e: - error = type(e)(self._redact(e)) - else: - error = None - if error is not None: - # Raised outside the except block: the original, unredacted error must not be its __context__. - raise error - body_bytes = response.content or b"" - body_str = body_bytes.decode("utf-8", errors="replace") - content_type = response.headers.get("Content-Type") or "" - self._raise_if_download_error(response, body_str, content_type) - - dirname = os.path.dirname(download_path) - if dirname: - os.makedirs(dirname, exist_ok=True) - with open(download_path, "wb") as f: - f.write(body_bytes) - - return ZBGetFileResponse({"local_file_path": download_path}) + url = f"{self.SCORING_BASE_URL if scoring else self.BULK_BASE_URL}/getfile" + return self._download(url, params, download_path, stream) def get_file( self, file_id: str, download_path: str, options: Optional[ZBGetFileOptions] = None, + stream: bool = False, ): """Allows you to get the validation results for the file you submitted @@ -1130,6 +1201,10 @@ def get_file( options: ZBGetFileOptions or None Optional download_type and activity_data (validation bulk getfile only). + stream: bool + When True, the file is written in chunks as it downloads instead of being held in memory first. + It appears at download_path only once complete. + Raises ------ ZBClientException @@ -1141,13 +1216,14 @@ def get_file( Returns a ZBGetFileResponse object if the request was successful """ - return self._get_file(False, file_id, download_path, options) + return self._get_file(False, file_id, download_path, options, stream=stream) def scoring_get_file( self, file_id: str, download_path: str, options: Optional[ZBGetFileOptions] = None, + stream: bool = False, ): """Allows you to get the validation results for the file you submitted @@ -1160,6 +1236,10 @@ def scoring_get_file( options: ZBGetFileOptions or None Optional download_type; activity_data is not sent for scoring getfile. + stream: bool + When True, the file is written in chunks as it downloads instead of being held in memory first. + It appears at download_path only once complete. + Raises ------ ZBClientException @@ -1171,7 +1251,7 @@ def scoring_get_file( Returns a ZBGetFileResponse object if the request was successful """ - return self._get_file(True, file_id, download_path, options) + return self._get_file(True, file_id, download_path, options, stream=stream) def _delete_file(self, scoring: bool, file_id: str): if not file_id.strip(): @@ -1289,8 +1369,9 @@ def send_email_finder_file_stream( def email_finder_file_status(self, file_id: str) -> ZBFileStatusResponse: return self._bulk_root_file_status(self.EMAIL_FINDER_BASE_URL, file_id) - def email_finder_get_file(self, file_id: str, download_path: str) -> ZBGetFileResponse: - return self._bulk_root_get_file(self.EMAIL_FINDER_BASE_URL, file_id, download_path) + def email_finder_get_file(self, file_id: str, download_path: str, stream: bool = False) -> ZBGetFileResponse: + """Save the results at ``download_path``; ``stream=True`` writes them in chunks instead of in memory.""" + return self._bulk_root_get_file(self.EMAIL_FINDER_BASE_URL, file_id, download_path, stream) def email_finder_delete_file(self, file_id: str) -> ZBDeleteFileResponse: return self._bulk_root_delete_file(self.EMAIL_FINDER_BASE_URL, file_id) @@ -1330,8 +1411,9 @@ def send_domain_search_file_stream( def domain_search_file_status(self, file_id: str) -> ZBFileStatusResponse: return self._bulk_root_file_status(self.DOMAIN_SEARCH_BASE_URL, file_id) - def domain_search_get_file(self, file_id: str, download_path: str) -> ZBGetFileResponse: - return self._bulk_root_get_file(self.DOMAIN_SEARCH_BASE_URL, file_id, download_path) + def domain_search_get_file(self, file_id: str, download_path: str, stream: bool = False) -> ZBGetFileResponse: + """Save the results at ``download_path``; ``stream=True`` writes them in chunks instead of in memory.""" + return self._bulk_root_get_file(self.DOMAIN_SEARCH_BASE_URL, file_id, download_path, stream) def domain_search_delete_file(self, file_id: str) -> ZBDeleteFileResponse: return self._bulk_root_delete_file(self.DOMAIN_SEARCH_BASE_URL, file_id) diff --git a/tests/test_streaming_and_regions_offline.py b/tests/test_streaming_and_regions_offline.py new file mode 100644 index 0000000..a215572 --- /dev/null +++ b/tests/test_streaming_and_regions_offline.py @@ -0,0 +1,145 @@ +"""Streamed downloads (opt-in) and the regional base_url warning.""" + +import io +import json +import tempfile +import warnings +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch + +from requests import Response +from requests.exceptions import ChunkedEncodingError + +from zerobouncesdk import ZBApiException, ZBApiUrl, ZeroBounce + +KEY = "secret-key-123" +CSV = b"email,status\n" + b"".join(b"user%06d@example.com,valid\n" % i for i in range(20000)) # ~600 KB + + +def _streamed(status, body, content_type, raw=None): + """A response whose body is read from ``raw`` as requests does with stream=True.""" + response = Response() + response.status_code = status + response.headers["Content-Type"] = content_type + response.raw = raw if raw is not None else io.BytesIO(body) + response.encoding = "utf-8" + return response + + +class _FailingRaw(io.BytesIO): + """Serves some bytes, then the connection drops.""" + + def read(self, *args, **kwargs): + data = super().read(*args, **kwargs) + if self.tell() > 100_000: + raise ChunkedEncodingError(f"Connection broken: https://bulkapi.zerobounce.net/v2/getfile?api_key={KEY}") + return data + + +class StreamedDownloadTest(TestCase): + def setUp(self): + self.calls = [] + self.reply = lambda request: _streamed(200, CSV, "text/csv") + + def send(session, request, **kwargs): + self.calls.append(kwargs.get("stream")) + return self.reply(request) + + patcher = patch("requests.sessions.Session.send", send) + patcher.start() + self.addCleanup(patcher.stop) + self.client = ZeroBounce(KEY) + self.folder = Path(tempfile.mkdtemp()) + + def _leftovers(self): + return [p.name for p in self.folder.rglob("*.part")] + + def test_every_download_method_streams_when_asked(self): + for name, call in ( + ("get_file", lambda path: self.client.get_file("file-9", path, stream=True)), + ("scoring_get_file", lambda path: self.client.scoring_get_file("file-9", path, stream=True)), + ("email_finder_get_file", lambda path: self.client.email_finder_get_file("file-9", path, stream=True)), + ("domain_search_get_file", lambda path: self.client.domain_search_get_file("file-9", path, stream=True)), + ): + path = self.folder / "nested" / f"{name}.csv" + result = call(str(path)) + self.assertEqual(result.local_file_path, str(path)) + self.assertEqual(path.read_bytes(), CSV, name) + self.assertEqual(self.calls, [True] * 4) + self.assertEqual(self._leftovers(), []) + + def test_without_the_flag_nothing_changes(self): + self.reply = lambda request: _streamed(200, CSV, "text/csv") + path = self.folder / "buffered.csv" + self.client.get_file("file-9", str(path)) + self.assertEqual(path.read_bytes(), CSV) + self.assertEqual(self.calls, [False]) # requests' default: the body is read into memory + + def test_error_bodies_raise_and_leave_no_file(self): + cases = [ + (200, json.dumps({"success": False, "message": "File cannot be found."}).encode(), "application/json", "File cannot be found."), + (403, b"Access denied" + b"x" * 3_000_000, "text/html", "Access denied"), + (200, b"Not a file", "text/html", "Not a file"), + (200, b"", "text/csv", "Empty response"), + (302, b"", "text/plain", "redirect"), + ] + for status, body, content_type, expected in cases: + self.reply = lambda request, s=status, b=body, c=content_type: _streamed(s, b, c) + path = self.folder / "result.csv" + with self.assertRaises(ZBApiException) as caught: + self.client.get_file("file-9", str(path), stream=True) + self.assertIn(expected, str(caught.exception)) + self.assertLess(len(str(caught.exception)), 300) + self.assertFalse(path.exists(), status) + self.assertEqual(self._leftovers(), []) + + def test_a_dropped_connection_is_redacted_and_leaves_no_partial_file(self): + self.reply = lambda request: _streamed(200, b"", "text/csv", raw=_FailingRaw(CSV)) + path = self.folder / "result.csv" + with self.assertRaises(ChunkedEncodingError) as caught: + self.client.get_file("file-9", str(path), stream=True) + self.assertNotIn(KEY, str(caught.exception)) + self.assertIsNone(caught.exception.__context__) + self.assertFalse(path.exists()) + self.assertEqual(self._leftovers(), []) + + def test_an_existing_file_is_only_replaced_by_a_complete_download(self): + path = self.folder / "result.csv" + path.write_bytes(b"previous results\n") + self.reply = lambda request: _streamed(200, b"", "text/csv", raw=_FailingRaw(CSV)) + with self.assertRaises(ChunkedEncodingError): + self.client.get_file("file-9", str(path), stream=True) + self.assertEqual(path.read_bytes(), b"previous results\n") + + +class RegionalBaseUrlTest(TestCase): + def setUp(self): + def send(session, request, **kwargs): + return _streamed(200, json.dumps({"success": True, "file_status": "Complete"}).encode(), "application/json") + + patcher = patch("requests.sessions.Session.send", send) + patcher.start() + self.addCleanup(patcher.stop) + + def _warnings(self, client, calls): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + for call in calls: + call(client) + return [w for w in caught if "regional host" in str(w.message)] + + def test_a_regional_base_url_warns_once_about_the_global_bulk_host(self): + for region in (ZBApiUrl.API_EU_URL, ZBApiUrl.API_USA_URL, "https://api-eu.zerobounce.net/v2"): + client = ZeroBounce(KEY, base_url=region) + found = self._warnings(client, [lambda c: c.file_status("f1"), lambda c: c.scoring_file_status("f1")]) + self.assertEqual(len(found), 1, region) + self.assertIn("bulkapi.zerobounce.net", str(found[0].message)) + self.assertNotIn(KEY, str(found[0].message)) + + def test_no_warning_for_the_default_host_or_single_requests_or_regional_bulk_urls(self): + self.assertEqual(self._warnings(ZeroBounce(KEY), [lambda c: c.file_status("f1")]), []) + self.assertEqual(self._warnings(ZeroBounce(KEY, base_url=ZBApiUrl.API_EU_URL), [lambda c: c.get_credits()]), []) + client = ZeroBounce(KEY, base_url=ZBApiUrl.API_EU_URL) + client.BULK_BASE_URL = "https://bulkapi-eu.example.com/v2" + self.assertEqual(self._warnings(client, [lambda c: c.file_status("f1")]), []) From 380c50b25dd7e3ba9f6158ab4ae14018afc3c259 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:39:17 +0400 Subject: [PATCH 17/18] ci: publish to PyPI with Trusted Publishing instead of an API token The zerobouncesdk project on PyPI now trusts this repository's publish.yml in the "release" environment, so pypa/gh-action-pypi-publish exchanges the job's OIDC token (id-token: write, already granted) for a short-lived upload token. The PYPI_API_TOKEN secret is no longer used; delete it and revoke the token on PyPI after the first release succeeds. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 4 ++-- AGENTS.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b87c849..089018b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -126,9 +126,9 @@ jobs: run: python -m build - name: Publish to PyPI + # Trusted Publishing: no stored token. PyPI accepts a short-lived OIDC token from this workflow + # (publish.yml, environment "release"), which is why the job has id-token: write. uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - password: ${{ secrets.PYPI_API_TOKEN }} release: name: Create GitHub Release diff --git a/AGENTS.md b/AGENTS.md index a5b4fd7..4b11506 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,4 +63,4 @@ Publishing and pushing tags need a maintainer's explicit approval. Do not bump t 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 `pyproject.toml` (`version = "x.y.z"`). The publish workflow fails if it differs from the tag. That is the only version field. There is no runtime `__version__` and no user-agent constant. `setup.py` does not contain a version. 3. `ruff check src tests`, `python -m compileall -q src tests`, and `python -m pytest`. -4. Tag `vx.y.z` on that commit and run the Publish workflow (`.github/workflows/publish.yml`) with that tag. It publishes `zerobouncesdk` to PyPI. +4. Tag `vx.y.z` on that commit and run the Publish workflow (`.github/workflows/publish.yml`) with that tag. It publishes `zerobouncesdk` to PyPI through PyPI Trusted Publishing (publisher: this repository, `publish.yml`, environment `release`); there is no PyPI token secret. From 59a28a7175bc1f985978d4b5a1ea022f16dcef00 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:46:20 +0400 Subject: [PATCH 18/18] docs: document PyPI Trusted Publishing as the only publishing method Correction to 380c50b: there was never a PyPI API token. The publish step's `password: ${{ secrets.PYPI_API_TOKEN }}` referenced a secret that was never set, so it expanded to nothing and pypa/gh-action-pypi-publish used Trusted Publishing for every release (the 2.2.4 run shows no password input and generated attestations, which PyPI only accepts from Trusted Publishing). Removing that line changed nothing at publish time; there is nothing to revoke. README's Publish section, AGENTS.md's release steps and the workflow comment now describe the setup: the trusted publisher (this repository, publish.yml, environment "release"), the OIDC exchange, and that renaming the workflow or environment needs the PyPI publisher updated first. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 5 +++-- AGENTS.md | 2 +- README.md | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 089018b..9eac5b3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -126,8 +126,9 @@ jobs: run: python -m build - name: Publish to PyPI - # Trusted Publishing: no stored token. PyPI accepts a short-lived OIDC token from this workflow - # (publish.yml, environment "release"), which is why the job has id-token: write. + # PyPI Trusted Publishing, the only way this package is published: the PyPI project trusts this + # repository's publish.yml in the "release" environment, and the action exchanges the job's OIDC + # token (id-token: write) for a short-lived upload token. No PyPI token exists or is needed. uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 release: diff --git a/AGENTS.md b/AGENTS.md index 4b11506..846ed3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,4 +63,4 @@ Publishing and pushing tags need a maintainer's explicit approval. Do not bump t 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 `pyproject.toml` (`version = "x.y.z"`). The publish workflow fails if it differs from the tag. That is the only version field. There is no runtime `__version__` and no user-agent constant. `setup.py` does not contain a version. 3. `ruff check src tests`, `python -m compileall -q src tests`, and `python -m pytest`. -4. Tag `vx.y.z` on that commit and run the Publish workflow (`.github/workflows/publish.yml`) with that tag. It publishes `zerobouncesdk` to PyPI through PyPI Trusted Publishing (publisher: this repository, `publish.yml`, environment `release`); there is no PyPI token secret. +4. Tag `vx.y.z` on that commit and run the Publish workflow (`.github/workflows/publish.yml`) with that tag. It publishes `zerobouncesdk` to PyPI through PyPI Trusted Publishing: the PyPI project trusts this repository, `publish.yml`, environment `release`. There is no PyPI token, and none should be added. Keep the workflow file name and environment name, or update the trusted publisher on PyPI first. diff --git a/README.md b/README.md index 967fe65..a0b80a5 100644 --- a/README.md +++ b/README.md @@ -664,3 +664,5 @@ OK 2. **Actions → Publish → Run workflow** with that tag. Registry: [zerobouncesdk on PyPI](https://pypi.org/project/zerobouncesdk/) + +The workflow publishes with [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/); no PyPI token is stored anywhere. The `zerobouncesdk` project on PyPI trusts this repository's `.github/workflows/publish.yml` running in the `release` environment, and the publish job (which has `id-token: write`) exchanges its GitHub OIDC token for a short-lived upload token. Each release also gets signed attestations. Renaming the workflow file or the environment breaks publishing until the trusted publisher on PyPI (project → Publishing) is updated to match.