diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 56343dc..7ac54ae 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -86,9 +86,15 @@ jobs: - name: Install dependencies run: pip install -e ".[test]" + - name: Lint + run: | + pip install ruff + ruff check src tests + 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..7d4ec91 100644 --- a/.github/workflows/sdk_ci.yml +++ b/.github/workflows/sdk_ci.yml @@ -27,7 +27,13 @@ jobs: - name: Install dependencies run: pip install -e ".[test]" + - name: Lint + run: | + pip install ruff + ruff check src tests + 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..5f0546b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# 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]" +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 +``` + +`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 + +- **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. `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. + +## 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. `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/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..f5bc8a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,12 @@ 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", "*_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/__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_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/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/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..caad7b4 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 @@ -16,6 +17,7 @@ ZBGetCreditsResponse, ZBGetApiUsageResponse, ZBGetActivityResponse, + ZBScoreEmailResponse, ZBValidateResponse, ZBValidateBatchElement, ZBValidateBatchResponse, @@ -29,10 +31,127 @@ ) +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.""" 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 @@ -105,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() @@ -137,33 +238,190 @@ 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 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) + ): + 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 + return response_class(self._read_json_payload(response)) + + def _post(self, url, response_class, data=None, json=None, files=None): + try: + 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: + data = {} + data = dict(data) + data["api_key"] = self._api_key + try: + 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 + 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: + 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: - json_response = response.json() - except ValueError as e: - raise ZBApiException from e + response = requests.request( + method, + url, + params=params, + 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 + return self._read_json_payload(response) + + 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) - error = json_response.pop("error", None) - if error: - raise ZBApiException(error) - return response_class(json_response) + 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 _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) + 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: - json_response = response.json() - except ValueError as e: - raise ZBApiException('Request not processed succesfully. Status code %s' % response.status_code) + response = requests.get( + 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 "" + 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}) + + 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}, + ) - return response_class(json_response) + 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. @@ -232,7 +490,56 @@ def get_activity(self, email: str): f"{self._base_url}/activity", ZBGetActivityResponse, params={"email": email} ) - def validate(self, email: str, ip_address: str = None): + 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): + 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, + use_post: bool = False, + ): """Validates the given email address. Parameters @@ -241,33 +548,69 @@ 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. + use_post: bool + When True, sends parameters as form-encoded POST body. 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" + + if use_post: + return self._post_form( + f"{self._base_url}/validate", + ZBValidateResponse, + data=params, + ) 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 +624,32 @@ 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 ], } - return self._post( + 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 + result = self._post( f"{self._base_url}/validatebatch", ZBValidateBatchResponse, - json=json) + 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, @@ -623,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: @@ -759,6 +1111,309 @@ 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 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: + 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 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: + 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 domain_column is None: + 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 domain_column is None: + 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 email_address_column is None: + 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 email_address_column is None: + 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 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}, + ) + def guess_format( self, domain: str, 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 new file mode 100644 index 0000000..9154bd6 --- /dev/null +++ b/tests/test_offline_api.py @@ -0,0 +1,620 @@ +"""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 + +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("