diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 7543836..c0641b6 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -81,6 +81,9 @@ jobs:
- name: Install dependencies
run: composer install --no-interaction --no-progress
+ - name: PHP syntax check
+ run: find src test -name '*.php' -exec php -l {} \;
+
- name: Run tests
run: vendor/bin/phpunit --colors=always
diff --git a/.github/workflows/sdk_ci.yml b/.github/workflows/sdk_ci.yml
index d3af245..2d5d3bf 100644
--- a/.github/workflows/sdk_ci.yml
+++ b/.github/workflows/sdk_ci.yml
@@ -27,5 +27,8 @@ jobs:
- name: Install dependencies
run: composer install --no-interaction --no-progress
+ - name: PHP syntax check
+ run: find src test -name '*.php' -exec php -l {} \;
+
- name: Run tests
run: vendor/bin/phpunit --colors=always
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..638bfa6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,61 @@
+# AGENTS.md
+
+Guidance for AI coding agents working in this repository.
+
+## Project
+
+`zero-bounce-php-sdk-setup` is the ZeroBounce PHP SDK, package `zero-bounce/sdk` on Packagist. Callers use `ZeroBounce::create()`, then `initialize($apiKey)` or `initialize($apiKey, $baseUrl)`. `ZeroBounce::Instance()` is a process-wide singleton. Failures throw `ZBException`. HTTP goes through cURL and `file_get_contents` behind `executeHttp`.
+
+## Layout
+
+- `src/ZeroBounce.php`: validation, bulk file, credits, activity, scoring. `executeHttp` is the HTTP call.
+- `src/ZeroBouncePriority3.php`: bulk email finder, domain search, list evaluator, filters, S3. It is a trait on `ZeroBounce`.
+- `src/ZBHttpError.php`: turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short `ZBException`.
+- `test/ZeroBounceTest.php`: PHPUnit cases with `MockZeroBounce`, which replaces `request`, `json`, `formPost`, `curl`, and `getBulkFileHttp`.
+- `test/OfflineApiTest.php`: offline suite. `test/OfflineHttp.php` records every request and answers without the network.
+- `composer.json`: the only version field, `"version": "x.y.z"`. `composer.lock` lists dependencies, not this package's version. There is no runtime version constant and no User-Agent.
+
+## Commands
+
+```bash
+composer install
+composer lint # php -l on src and test
+composer test # phpunit
+```
+
+`php -l` is the syntax check. There is no separate typechecker. CI (`.github/workflows/sdk_ci.yml`, PHP 8.2) installs dependencies, runs `php -l`, then `vendor/bin/phpunit`. On PHP 8.5, `php -l src/ZBLegacyHttpResponseHeaders.php` prints a deprecation for `$http_response_header`. That file is loaded only on PHP earlier than 8.5. Leave it in place.
+
+## Conventions
+
+- **One client per API key.** `Instance()` is process-wide. Do not reuse it across users or requests. Call `create()` and `initialize` for each key. The API base URL is stored on the instance.
+- **Do not treat `0` as missing.** `if ($column)` and `empty($column)` drop `0`. Unset is `null` or `''`. `0` and `false` are sent. `null` omits an optional parameter.
+- **Column indexes are 1-based on the API and are not converted.** The SDK forwards the integer it is given, including `0`, for required and optional columns. `sendFile` does not have `remove_duplicate`. That flag is the `remove_duplicate` key on the S3 option array.
+- **Booleans are the strings `true` and `false`** on query strings, form bodies, and multipart fields. A PHP bool would be sent as `1` or `0`. On `validateBatch` JSON, `activity_data` and `verify_plus` are JSON booleans.
+- **`has_header_row`:** `null` is omitted, `false` is sent as `false`, `true` is sent as `true`. The same rule applies to `allow_phase_2`, `activity_data`, and `verify_plus`.
+- **`allow_phase_2` is validation `sendFile` and `validateS3File` only.** Scoring `sendFile` ignores it. `activity_data` on get-file is validation `getFile` only.
+- **`validate` timeout is 3–60 seconds. `validateBatch` timeout is 10–120.** An out-of-range timeout throws before a request. `null` omits `timeout`.
+- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. Bulk file, email finder, domain search, list evaluator, and S3 use `bulkapi.zerobounce.net`. Filters and single-email calls use the host from `initialize`.
+- **`ZBBaseUrl` accepts only the default, US, and EU hosts.** Any other value falls back to the default. There is no extra `https://` check.
+- **Failures throw `ZBException`.** `ZBMissingApiKeyException` and `ZBMissingParameterException` extend it. The message must be short: no raw HTML, no megabyte body, and no `Error: Error:` prefix. An account-level `validateBatch` error (`email_address` `all`) throws. A per-address error stays on `$response->errors`.
+- **`getCredits` can return `Credits` `-1` for a bad key.** That is a normal response. Throw for an `error` string, an HTML page, or a non-JSON body.
+- **`executeHttp` does not follow redirects.** `CURLOPT_FOLLOWLOCATION` is false. `file_get_contents` uses `follow_location` 0 and `max_redirects` 0. A 3xx response is an error.
+- **Tests override `executeHttp`.** It is `protected`. Subclass `ZeroBounce` in the test. Do not call the live API from `composer test`. Do not commit `vendor/`, `.env`, or `.phpunit.cache`. `composer.lock` is committed.
+- Match the surrounding style. The library still runs on PHP 7.0: no scalar property types, `fn()`, or `str_contains` in `src/`.
+
+## 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 `composer.json` (`"version"`). The publish workflow fails if it differs from the tag. That is the only version field.
+3. `composer lint` and `composer test`.
+4. Tag `vx.y.z` on that commit and run the Publish workflow with that tag. It creates the GitHub release and tells Packagist to update `zero-bounce/sdk`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8a793ca
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,216 @@
+# 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
+
+- `ZeroBounce::create()` returns a new client. `Instance()` stays a process-wide singleton.
+- `validate` and `validateBatch` take `ZBValidateOptions`: `timeout`, `activityData`, `verifyPlus`, and `usePost` (form-encoded `POST /validate`). Single `timeout` is 3–60 seconds. Batch `timeout` is 10–120.
+- `ZBValidateResponse` reads `active_in_days` and `active_first_seen`.
+- `scoreEmail` (`GET /v2/scoring`) and `scoreEmailPost` (form-encoded `POST /v2/scoring`).
+- Bulk email finder (`sendEmailFinderFile`, `emailFinderFileStatus`, `emailFinderGetFile`, `emailFinderDeleteFile`, plus stream upload).
+- Bulk domain search (`sendDomainSearchFile`, `domainSearchFileStatus`, `domainSearchGetFile`, `domainSearchDeleteFile`, plus stream upload).
+- List evaluator (`sendListEvaluatorFile`, `getListEvaluatorResult`, `sendEvaluatedFile`, plus stream uploads).
+- Filters (`addFilter`, `listFilters`, `deleteFilter`).
+- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3Files`).
+
+### Changed
+
+- API requests time out after 120 seconds.
+- Each `ZeroBounce` instance keeps its own API base URL.
+
+### Fixed
+
+- A 403 HTML "Access denied" page is a short `ZBException`. An HTML page with status 200 is an error as well, including `getFile`, `emailFinderGetFile`, and `domainSearchGetFile`, instead of a saved CSV.
+- Empty and non-JSON bodies are a short error. A repeated `Error:` / `[Error]:` prefix is removed.
+- `validateBatch` throws when the API returns an account-level error for address `all` (invalid API key or no credits). An error for one address stays on the successful result.
+- `has_header_row` is sent as `false` when the caller sets it to false, on `sendFile`, `scoringSendFile`, bulk email finder, and domain search.
+- Column `0` is sent. That includes the required email and domain columns and optional name, gender, and IP columns. `null` still omits an optional column. `validate` sends IP address `0`. A filter `value` of `0` is sent.
+- `validate` rejects a timeout outside 3–60 seconds, and `validateBatch` rejects a timeout outside 10–120 seconds, before a request is sent.
+- `validateS3File`, `scoreS3File`, and `exportS3File` reject an empty API key before a request is sent.
+
+### Security
+
+- API requests do not follow redirects, so a 3xx response cannot send the API key to another host.
+- Exception messages no longer include the raw HTML access-denied page.
+
+## [2.1.10] - 2026-06-18
+
+### Fixed
+
+- `validateBatch` posts to `api.zerobounce.net/v2/validatebatch` instead of `bulkapi.zerobounce.net`.
+
+## [2.1.9] - 2026-06-10
+
+### Added
+
+- `ZBValidateResponse.catchallDomain`, read from `catchall_domain`.
+
+## [2.1.8] - 2026-04-22
+
+### Fixed
+
+- An empty HTTP header list is treated as "no status" instead of raising a warning while reading the status code.
+
+## [2.1.6] - 2026-04-16
+
+### Fixed
+
+- On PHP 8.5, reading response headers no longer touches the deprecated `$http_response_header` variable.
+
+## [2.1.1] - 2026-04-15
+
+### Fixed
+
+- `getFile` still reads response headers on PHP 8.5 when `http_get_last_response_headers()` does not return an array.
+
+## [2.1.0] - 2026-04-05
+
+### Added
+
+- `getFile` and `scoringGetFile` accept `ZBGetFileOptions`: `downloadType` (`phase_1`, `phase_2`, `combined`) and `activityData` (validation `getFile` only).
+- `sendFile` and `sendFileStream` accept `allowPhase2`, sent as `allow_phase_2` on validation bulk sendfile only.
+- `ZBFileStatusResponse.filePhase2Status`.
+
+## [2.0.11] - 2026-03-03
+
+### Changed
+
+- License the project under MIT.
+
+### Removed
+
+- The Spanish documentation file.
+
+## [2.0.0] - 2026-02-26
+
+### Added
+
+- `ZBResponse::toArray()`.
+- API usage totals for `accept_all`, `mx_forward`, `alternate`, `allowed`, `blocked`, `gold`, and `role_based_accept_all`.
+
+### Changed
+
+- `guessFormat` emits `E_USER_DEPRECATED`. Use `findEmail` or `findEmailFormat`.
+- A response that is not a JSON object or array throws `ZBException`.
+
+## [1.2.0] - 2025-10-27
+
+### Added
+
+- `initialize($apiKey, $baseUrl)` with `ZBBaseUrl::API_DEFAULT_URL`, `API_USA_URL`, and `API_EU_URL`.
+- `findEmail` and `findEmailFormat`.
+- `ZBValidateSubStatus::AcceptAll`.
+
+### Deprecated
+
+- `guessFormat`. Use `findEmail` or `findEmailFormat`.
+
+## [1.1.4] - 2023-11-26
+
+### Fixed
+
+- A body that is not a JSON object or array throws `ZBException` instead of being ignored.
+- Response fields start as `null` until the API sets them.
+
+### Changed
+
+- Missing parameters, a missing API key, and other SDK failures use separate exception classes.
+
+## [1.1.3] - 2023-09-05
+
+### Added
+
+- `guessFormat`.
+
+## [1.1.2] - 2023-08-22
+
+### Fixed
+
+- `getFile` calls the bulk get-file path.
+
+## [1.1.1] - 2023-08-17
+
+### Fixed
+
+- `sendFile` calls the bulk send-file path.
+
+## [1.1.0] - 2023-05-17
+
+### Added
+
+- `validateBatch`.
+
+## [1.0.2] - 2023-05-11
+
+### Fixed
+
+- A missing API response no longer raises a warning.
+
+## [1.0.1] - 2023-04-27
+
+### Fixed
+
+- Removed an import that broke installation under Laravel.
+
+## [1.0.0] - 2023-04-06
+
+### Added
+
+- `getActivity`.
+
+## [0.0.4] - 2020-02-24
+
+### Fixed
+
+- The client runs on PHP 7.0.
+
+## [0.0.3] - 2020-02-11
+
+### Added
+
+- Scoring bulk file methods: `scoringSendFile`, `scoringFileStatus`, `scoringGetFile`, and `scoringDeleteFile`.
+
+### Changed
+
+- The package requires PHP 7.0 or newer.
+
+## [0.0.2] - 2019-08-05
+
+### Changed
+
+- Composer no longer requires the `ext-spltype` and `ext-http` extensions.
+
+## [0.0.1] - 2019-08-05
+
+### Added
+
+- Initial release: `getCredits`, `getApiUsage`, `validate`, and the bulk file methods.
+
+[Unreleased]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.11...HEAD
+[2.1.10]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.9...v2.1.10
+[2.1.9]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.8...v2.1.9
+[2.1.8]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.6...v2.1.8
+[2.1.6]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.1...v2.1.6
+[2.1.1]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.0...v2.1.1
+[2.1.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.0.11...v2.1.0
+[2.0.11]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.0.0...v2.0.11
+[2.0.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.2.0...v2.0.0
+[1.2.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.4...v1.2.0
+[1.1.4]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.3...v1.1.4
+[1.1.3]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.2...v1.1.3
+[1.1.2]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.1...v1.1.2
+[1.1.1]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.0...v1.1.1
+[1.1.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.0.2...v1.1.0
+[1.0.2]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.0.1...v1.0.2
+[1.0.1]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.0.0...v1.0.1
+[1.0.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v0.0.4...v1.0.0
+[0.0.4]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v0.0.3...v0.0.4
+[0.0.3]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v0.0.2...v0.0.3
+[0.0.2]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v0.0.1...v0.0.2
+[0.0.1]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/releases/tag/v0.0.1
diff --git a/composer.json b/composer.json
index 049f2ae..95acc89 100644
--- a/composer.json
+++ b/composer.json
@@ -9,7 +9,9 @@
],
"license": "MIT",
"scripts": {
- "dumpautoload": "composer dumpautoload -o"
+ "dumpautoload": "composer dumpautoload -o",
+ "lint": "find src test -name '*.php' -exec php -l {} \\;",
+ "test": "phpunit --colors=always"
},
"authors": [
{
diff --git a/src/ZBHttpError.php b/src/ZBHttpError.php
new file mode 100644
index 0000000..3d77933
--- /dev/null
+++ b/src/ZBHttpError.php
@@ -0,0 +1,178 @@
+ 500) {
+ return 'ZeroBounce returned a response that is not valid JSON.';
+ }
+ return self::stripPrefix($trimmed);
+ }
+ if (strlen($trimmed) > 1000) {
+ return 'ZeroBounce returned a response that is not valid JSON.';
+ }
+ $message = self::stripPrefix($trimmed);
+ if ($message === '') {
+ return self::emptyMessage($status);
+ }
+ return $message;
+ }
+
+ public static function throwIfBareAccountError($decoded)
+ {
+ if (!is_array($decoded) || self::isList($decoded)) {
+ return;
+ }
+ if (!isset($decoded['error']) || !is_string($decoded['error']) || trim($decoded['error']) === '') {
+ return;
+ }
+ $payloadKeys = array(
+ 'address', 'email_batch', 'Credits', 'credits', 'file_id', 'success', 'found',
+ 'total', 'email', 'format', 'domain', 'file_status', 'echo_api_key',
+ );
+ foreach ($payloadKeys as $key) {
+ if (array_key_exists($key, $decoded)) {
+ return;
+ }
+ }
+ throw new ZBException(self::stripPrefix(trim($decoded['error'])));
+ }
+
+ public static function stripPrefix($message)
+ {
+ $message = trim((string)$message);
+ while ($message !== '') {
+ if (stripos($message, 'Error:') === 0) {
+ $message = trim(substr($message, strlen('Error:')));
+ continue;
+ }
+ if (stripos($message, '[Error]:') === 0) {
+ $message = trim(substr($message, strlen('[Error]:')));
+ continue;
+ }
+ break;
+ }
+ return $message;
+ }
+
+ private static function htmlTitle($body)
+ {
+ $lower = strtolower((string)$body);
+ $start = strpos($lower, '
', $start);
+ if ($openEnd === false) {
+ return '';
+ }
+ $close = strpos($lower, '', $openEnd);
+ if ($close === false) {
+ return '';
+ }
+ return trim(substr($body, $openEnd + 1, $close - $openEnd - 1));
+ }
+
+ private static function hasErrorOrMessageKey(array $decoded)
+ {
+ foreach ($decoded as $key => $value) {
+ $name = (string)$key;
+ if (stripos($name, 'error') !== false || stripos($name, 'message') !== false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static function stringField(array $decoded)
+ {
+ foreach (array('message', 'error', 'error_message') as $key) {
+ if (!isset($decoded[$key]) || $decoded[$key] === '' || $decoded[$key] === null || $decoded[$key] === array()) {
+ continue;
+ }
+ if (is_string($decoded[$key]) && trim($decoded[$key]) !== '') {
+ return trim($decoded[$key]);
+ }
+ if (is_array($decoded[$key]) && isset($decoded[$key][0]) && is_string($decoded[$key][0]) && trim($decoded[$key][0]) !== '') {
+ return trim($decoded[$key][0]);
+ }
+ }
+ return '';
+ }
+
+ private static function isList(array $value)
+ {
+ if ($value === array()) {
+ return true;
+ }
+ return array_keys($value) === range(0, count($value) - 1);
+ }
+}
diff --git a/src/ZBRawJsonResponse.php b/src/ZBRawJsonResponse.php
new file mode 100644
index 0000000..f9f5d79
--- /dev/null
+++ b/src/ZBRawJsonResponse.php
@@ -0,0 +1,32 @@
+ */
+ public $data = array();
+
+ /**
+ * @param string|array $json
+ * @throws ZBException
+ */
+ public function Deserialize($json)
+ {
+ if (is_string($json)) {
+ $decoded = json_decode($json, true);
+ if (!is_array($decoded)) {
+ throw new ZBException(sprintf('Invalid response "%s".', $json));
+ }
+ $this->data = $decoded;
+ return;
+ }
+ if (!is_array($json)) {
+ throw new ZBException('Deserialize expects a JSON string or array.');
+ }
+ $this->data = $json;
+ }
+}
diff --git a/src/ZBScoreEmailResponse.php b/src/ZBScoreEmailResponse.php
new file mode 100644
index 0000000..a891588
--- /dev/null
+++ b/src/ZBScoreEmailResponse.php
@@ -0,0 +1,23 @@
+email . ", " .
+ "score=" . $this->score . "}";
+ }
+}
diff --git a/src/ZBValidateOptions.php b/src/ZBValidateOptions.php
new file mode 100644
index 0000000..4263aa7
--- /dev/null
+++ b/src/ZBValidateOptions.php
@@ -0,0 +1,21 @@
+apiBaseUrl = ZBBaseUrl::API_DEFAULT_URL;
}
- protected static $ApiBaseUrl;
+ /** @var string */
+ protected $apiBaseUrl;
const BulkApiBaseUrl = "https://bulkapi.zerobounce.net/v2";
private $apiKey = null;
@@ -44,7 +56,7 @@ protected function __construct()
public function initialize($apiKey, $baseUrl = null)
{
$this->apiKey = $apiKey;
- self::$ApiBaseUrl = ZBBaseUrl::getByValue($baseUrl);
+ $this->apiBaseUrl = ZBBaseUrl::getByValue($baseUrl);
}
/**
@@ -55,13 +67,48 @@ public function initialize($apiKey, $baseUrl = null)
* @throws ZBMissingParameterException
* @throws ZBException
*/
- public function validate($email, $ipAddress = NULL)
+ public function validate($email, $ipAddress = NULL, $options = NULL)
{
$this->checkValidApiKey();
if (!$email) throw new ZBMissingParameterException("email is required");
+ if ($options instanceof ZBValidateOptions) {
+ $this->checkTimeout($options->timeout, false);
+ }
$response = new ZBValidateResponse();
- $ipPart = $ipAddress ? urlencode($ipAddress) : "";
- $this->request(self::$ApiBaseUrl . "/validate?api_key=" . urlencode($this->apiKey) . "&email=" . urlencode($email) . "&ip_address=" . $ipPart, $response);
+ $ipValue = ($ipAddress === null) ? '' : (string)$ipAddress;
+ $fields = array(
+ 'email' => $email,
+ 'ip_address' => $ipValue,
+ );
+ if ($options instanceof ZBValidateOptions) {
+ if ($options->timeout !== null) {
+ $fields['timeout'] = (string)$options->timeout;
+ }
+ if ($options->activityData !== null) {
+ $fields['activity_data'] = $options->activityData ? "true" : "false";
+ }
+ if ($options->verifyPlus !== null) {
+ $fields['verify_plus'] = $options->verifyPlus ? "true" : "false";
+ }
+ if ($options->usePost) {
+ $this->formPost($this->apiBaseUrl . "/validate", $fields, $response);
+ return $response;
+ }
+ }
+ $url = $this->apiBaseUrl . "/validate?api_key=" . urlencode($this->apiKey)
+ . "&email=" . urlencode($email) . "&ip_address=" . urlencode($ipValue);
+ if ($options instanceof ZBValidateOptions) {
+ if ($options->timeout !== null) {
+ $url .= "&timeout=" . urlencode((string)$options->timeout);
+ }
+ if ($options->activityData !== null) {
+ $url .= "&activity_data=" . ($options->activityData ? "true" : "false");
+ }
+ if ($options->verifyPlus !== null) {
+ $url .= "&verify_plus=" . ($options->verifyPlus ? "true" : "false");
+ }
+ }
+ $this->request($url, $response);
return $response;
}
@@ -72,10 +119,13 @@ public function validate($email, $ipAddress = NULL)
* @throws ZBMissingParameterException
* @throws ZBException
*/
- public function validateBatch($emails)
+ public function validateBatch($emails, $options = NULL)
{
$this->checkValidApiKey();
if (!$emails or !count($emails)) throw new ZBMissingParameterException("emails parameter is required");
+ if ($options instanceof ZBValidateOptions) {
+ $this->checkTimeout($options->timeout, true);
+ }
$params = [];
if (gettype($emails[0]) === 'string')
@@ -98,10 +148,22 @@ function ($email) {
else
throw new ZBException('Unknown Parameter Type');
$params = ['email_batch' => $params];
+ if ($options instanceof ZBValidateOptions) {
+ if ($options->timeout !== null) {
+ $params['timeout'] = $options->timeout;
+ }
+ if ($options->activityData !== null) {
+ $params['activity_data'] = $options->activityData;
+ }
+ if ($options->verifyPlus !== null) {
+ $params['verify_plus'] = $options->verifyPlus;
+ }
+ }
$response = new ZBBatchValidateResponse();
- $code = $this->json(
- self::$ApiBaseUrl . "/validatebatch", $params, $response);
+ $this->json(
+ $this->apiBaseUrl . "/validatebatch", $params, $response);
+ $this->throwIfBatchAccountError($response);
return $response;
}
@@ -114,7 +176,7 @@ public function getCredits()
{
$this->checkValidApiKey();
$response = new ZBGetCreditsResponse();
- $this->request(self::$ApiBaseUrl . "/getcredits?api_key=" . urlencode($this->apiKey), $response);
+ $this->request($this->apiBaseUrl . "/getcredits?api_key=" . urlencode($this->apiKey), $response);
return $response;
}
@@ -134,7 +196,7 @@ public function getApiUsage($startDate, $endDate)
$response = new ZBApiUsageResponse();
$format = "Y-m-d";
- $this->request(self::$ApiBaseUrl . "/getapiusage?api_key=" . urlencode($this->apiKey)
+ $this->request($this->apiBaseUrl . "/getapiusage?api_key=" . urlencode($this->apiKey)
. "&start_date=" . $startDate->format($format)
. "&end_date=" . $endDate->format($format),
$response);
@@ -155,12 +217,48 @@ public function getActivity($email)
$response = new ZBActivityResponse();
$format = "Y-m-d";
- $this->request(self::$ApiBaseUrl . "/activity?api_key=" . urlencode($this->apiKey)
+ $this->request($this->apiBaseUrl . "/activity?api_key=" . urlencode($this->apiKey)
+ . "&email=" . urlencode($email),
+ $response);
+ return $response;
+ }
+
+ /**
+ * Real-time AI scoring for a single email (GET /scoring).
+ * @param string $email
+ * @return ZBScoreEmailResponse
+ * @throws ZBMissingApiKeyException
+ * @throws ZBException
+ */
+ public function scoreEmail($email)
+ {
+ $this->checkValidApiKey();
+ if (!$email) throw new ZBMissingParameterException("email is required");
+
+ $response = new ZBScoreEmailResponse();
+ $this->request($this->apiBaseUrl . "/scoring?api_key=" . urlencode($this->apiKey)
. "&email=" . urlencode($email),
$response);
return $response;
}
+ /**
+ * Real-time AI scoring for a single email (POST /scoring, form-encoded).
+ * @param string $email
+ * @return ZBScoreEmailResponse
+ * @throws ZBMissingApiKeyException
+ * @throws ZBException
+ */
+ public function scoreEmailPost($email)
+ {
+ $this->checkValidApiKey();
+ if (!$email) throw new ZBMissingParameterException("email is required");
+
+ $response = new ZBScoreEmailResponse();
+ $this->formPost($this->apiBaseUrl . "/scoring", array('email' => $email), $response);
+ return $response;
+ }
+
/**
* The sendfile API allows user to send a file for bulk email validation
* @param string $filepath
@@ -271,25 +369,14 @@ private function _sendFile(
{
$this->checkValidApiKey();
- // data fields for POST request
-
- if (!$filepath) throw new ZBMissingParameterException("filePath is required");
- if (!$emailAddressColumn) throw new ZBMissingParameterException("emailAddressColumn is required");
+ $this->requireProvided($filepath, "filePath is required");
+ $this->requireProvided($emailAddressColumn, "emailAddressColumn is required");
try {
- $fields = array(
- "api_key" => $this->apiKey,
- "email_address_column" => $emailAddressColumn
+ $fields = $this->multipartFileFields(
+ $emailAddressColumn, $returnUrl, $firstNameColumn, $lastNameColumn,
+ $genderColumn, $ipAddressColumn, $hasHeaderRow, $allowPhase2, $url
);
- if ($returnUrl) $fields["return_url"] = $returnUrl;
- if ($firstNameColumn) $fields["first_name_column"] = $firstNameColumn;
- if ($lastNameColumn) $fields["last_name_column"] = $lastNameColumn;
- if ($genderColumn) $fields["gender_column"] = $genderColumn;
- if ($ipAddressColumn) $fields["ip_address_column"] = $ipAddressColumn;
- if ($hasHeaderRow) $fields["has_header_row"] = 'true';
- if ($allowPhase2 !== null && strpos($url, '/scoring/') === false) {
- $fields['allow_phase_2'] = $allowPhase2 ? 'true' : 'false';
- }
$files = array();
$files[$filepath] = file_get_contents($filepath);
@@ -324,23 +411,14 @@ private function _sendFileWithContent(
$allowPhase2 = null)
{
$this->checkValidApiKey();
- if (!$fileName) throw new ZBMissingParameterException("fileName is required");
- if (!$emailAddressColumn) throw new ZBMissingParameterException("emailAddressColumn is required");
+ $this->requireProvided($fileName, "fileName is required");
+ $this->requireProvided($emailAddressColumn, "emailAddressColumn is required");
try {
- $fields = array(
- "api_key" => $this->apiKey,
- "email_address_column" => $emailAddressColumn
+ $fields = $this->multipartFileFields(
+ $emailAddressColumn, $returnUrl, $firstNameColumn, $lastNameColumn,
+ $genderColumn, $ipAddressColumn, $hasHeaderRow, $allowPhase2, $url
);
- if ($returnUrl) $fields["return_url"] = $returnUrl;
- if ($firstNameColumn) $fields["first_name_column"] = $firstNameColumn;
- if ($lastNameColumn) $fields["last_name_column"] = $lastNameColumn;
- if ($genderColumn) $fields["gender_column"] = $genderColumn;
- if ($ipAddressColumn) $fields["ip_address_column"] = $ipAddressColumn;
- if ($hasHeaderRow) $fields["has_header_row"] = 'true';
- if ($allowPhase2 !== null && strpos($url, '/scoring/') === false) {
- $fields['allow_phase_2'] = $allowPhase2 ? 'true' : 'false';
- }
$files = array($fileName => $content);
@@ -364,30 +442,41 @@ protected function json($url, $data, $response)
{
$data['api_key'] = $this->apiKey;
$content = json_encode($data);
+ $result = $this->executeHttp(
+ 'POST',
+ $url,
+ array('Content-Type: application/json'),
+ $content,
+ 120
+ );
- $curl = curl_init($url);
- curl_setopt_array($curl, array(
- CURLOPT_URL => $url,
- CURLOPT_HEADER => false,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_TIMEOUT => 120,
- CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
- CURLOPT_POSTFIELDS => $content
- ));
-
- $json = curl_exec($curl);
- $err = curl_error($curl);
-
- $code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
-
- curl_close($curl);
+ $json = $this->readJsonBody($result);
+ $response->Deserialize($json);
+ return (int)$result['status'];
+ }
- if (!$json) {
- throw new ZBException("No response");
- }
+ /**
+ * @param string $url
+ * @param array $data
+ * @param ZBResponse $response
+ * @return int http statusCode
+ * @throws ZBException
+ */
+ protected function formPost($url, $data, $response)
+ {
+ $data['api_key'] = $this->apiKey;
+ $content = http_build_query($data);
+ $result = $this->executeHttp(
+ 'POST',
+ $url,
+ array('Content-Type: application/x-www-form-urlencoded'),
+ $content,
+ 120
+ );
+ $json = $this->readJsonBody($result);
$response->Deserialize($json);
- return $code;
+ return (int)$result['status'];
}
/**
@@ -399,39 +488,21 @@ protected function json($url, $data, $response)
*/
protected function curl($url, $fields, $files)
{
- $curl = curl_init();
-
$boundary = bin2hex(random_bytes(16));
$delimiter = '-------------' . $boundary;
-
$post_data = $this->build_data_files($boundary, $fields, $files);
-
- curl_setopt_array($curl, array(
- CURLOPT_URL => $url,
- CURLOPT_RETURNTRANSFER => 1,
- CURLOPT_MAXREDIRS => 10,
- CURLOPT_TIMEOUT => 120,
- //CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
- CURLOPT_CUSTOMREQUEST => "POST",
- CURLOPT_POST => 1,
- CURLOPT_POSTFIELDS => $post_data,
- CURLOPT_HTTPHEADER => array(
- //"Authorization: Bearer $TOKEN",
+ $result = $this->executeHttp(
+ 'POST',
+ $url,
+ array(
"Content-Type: multipart/form-data; boundary=" . $delimiter,
- "Content-Length: " . strlen($post_data)
+ "Content-Length: " . strlen($post_data),
),
- ));
-
- $response = curl_exec($curl);
- $err = curl_error($curl);
-
- curl_close($curl);
-
- /*if ($err && count_chars($err) > 0) {
- throw new ZBApiException($err);
- }*/
+ $post_data,
+ 120
+ );
- return $response;
+ return $this->readJsonBody($result);
}
/**
@@ -467,7 +538,7 @@ private function _fileStatus($scoring, $fileId)
{
$this->checkValidApiKey();
- if (!$fileId) throw new ZBMissingParameterException("fileId is required");
+ $this->requireProvided($fileId, "fileId is required");
$response = new ZBFileStatusResponse();
$this->request(self::BulkApiBaseUrl
@@ -520,8 +591,8 @@ private function _getFile($scoring, $fileId, $downloadPath, $options = null)
{
$this->checkValidApiKey();
- if (!$fileId) throw new ZBMissingParameterException("fileId is required");
- if (!$downloadPath) throw new ZBMissingParameterException("downloadPath is required");
+ $this->requireProvided($fileId, "fileId is required");
+ $this->requireProvided($downloadPath, "downloadPath is required");
try {
$query = array(
@@ -540,32 +611,7 @@ private function _getFile($scoring, $fileId, $downloadPath, $options = null)
$path = ($scoring ? '/scoring' : '') . '/getfile';
$url = self::BulkApiBaseUrl . $path . '?' . http_build_query($query);
- $result = $this->getBulkFileHttp($url);
- $body = $result['body'];
- $headers = $result['headers'];
- $code = self::parseHttpStatusFromHeaders($headers);
- $contentType = self::parseContentTypeFromHeaders($headers);
-
- if ($code > 299) {
- throw new ZBException(self::formatGetFileErrorMessage($body));
- }
-
- if (self::shouldTreatGetFileBodyAsError($body, $contentType)) {
- throw new ZBException(self::formatGetFileErrorMessage($body));
- }
-
- $folder = dirname($downloadPath);
- if ($folder !== '' && $folder !== '.' && !file_exists($folder)) {
- mkdir($folder, 0777, true);
- }
-
- if (file_put_contents($downloadPath, $body) === false) {
- throw new ZBException('Could not write file');
- }
-
- $response = new ZBGetFileResponse();
- $response->localFilePath = $downloadPath;
- return $response;
+ return $this->saveBulkDownload($this->getBulkFileHttp($url), $downloadPath);
} catch (Exception $e) {
throw new ZBException($this->redactApiKey($e->getMessage()));
}
@@ -591,27 +637,11 @@ private function redactApiKey($message)
*/
protected function getBulkFileHttp($url)
{
- $context = stream_context_create(array(
- 'http' => array(
- 'ignore_errors' => true,
- 'timeout' => 120,
- ),
- ));
- $body = @file_get_contents($url, false, $context);
- if ($body === false) {
+ $result = $this->executeHttp('GET', $url, array(), null, 120);
+ if ($result['body'] === '' && (int)$result['status'] === 0) {
throw new ZBException('No response');
}
- $headers = array();
- if (function_exists('http_get_last_response_headers')) {
- // @see https://wiki.php.net/rfc/deprecations_php_8_5#deprecate_the_http_response_header_predefined_variable
- $fromFn = http_get_last_response_headers();
- if (is_array($fromFn)) {
- $headers = $fromFn;
- }
- } elseif (\PHP_VERSION_ID < 80500) {
- $headers = require __DIR__ . '/ZBLegacyHttpResponseHeaders.php';
- }
- return array('body' => $body, 'headers' => $headers);
+ return array('body' => $result['body'], 'headers' => $result['headers']);
}
/**
@@ -708,7 +738,7 @@ protected static function formatGetFileErrorMessage($body)
{
$o = json_decode($body, true);
if (!is_array($o)) {
- return $body !== '' ? $body : 'Invalid getfile response';
+ return ZBHttpError::message(0, $body);
}
foreach (array('message', 'error', 'error_message') as $k) {
if (empty($o[$k])) {
@@ -721,7 +751,7 @@ protected static function formatGetFileErrorMessage($body)
return trim($o[$k][0]);
}
}
- return $body;
+ return ZBHttpError::message(0, $body);
}
@@ -758,7 +788,7 @@ public function _deleteFile($scoring, $fileId)
{
$this->checkValidApiKey();
- if (!$fileId) throw new ZBMissingParameterException("fileId is required");
+ $this->requireProvided($fileId, "fileId is required");
$response = new ZBDeleteFileResponse();
$this->request(self::BulkApiBaseUrl
@@ -771,45 +801,272 @@ public function _deleteFile($scoring, $fileId)
}
/**
+ * Lowest HTTP call. Tests override this and never touch the network.
+ *
+ * @param string $method
* @param string $url
- * @param ZBResponse $response
- * @return int http statusCode
+ * @param string[] $headers
+ * @param string|null $body
+ * @param int $timeout
+ * @return array{status: int, headers: string[], body: string}
* @throws ZBException
*/
- protected function request($url, $response)
+ protected function executeHttp($method, $url, array $headers = array(), $body = null, $timeout = 120)
{
- try {
- $context = stream_context_create(array(
- 'http' => array(
- 'ignore_errors' => true,
- 'timeout' => 120,
- )
- ));
-
- $json = @file_get_contents($url, false, $context);
-
- if (!$json) {
- throw new ZBException("No response");
+ $method = strtoupper($method);
+ if ($method === 'GET' && $body === null) {
+ $contextOptions = array(
+ 'method' => 'GET',
+ 'ignore_errors' => true,
+ 'timeout' => $timeout,
+ 'follow_location' => 0,
+ 'max_redirects' => 0,
+ );
+ if ($headers) {
+ $contextOptions['header'] = implode("\r\n", $headers);
}
-
- $responseHeaders = null;
- if (function_exists('http_get_last_response_headers')) {
- // @see https://wiki.php.net/rfc/deprecations_php_8_5#deprecate_the_http_response_header_predefined_variable
- $responseHeaders = http_get_last_response_headers();
- } elseif (\PHP_VERSION_ID < 80500) {
- $responseHeaders = require __DIR__ . '/ZBLegacyHttpResponseHeaders.php';
+ $context = stream_context_create(array('http' => $contextOptions));
+ $responseBody = @file_get_contents($url, false, $context);
+ if ($responseBody === false) {
+ $responseBody = '';
}
- if (!is_array($responseHeaders)) {
- $responseHeaders = array();
+ $responseHeaders = $this->lastHttpHeaders();
+ return array(
+ 'status' => (int)$this->getHttpCode($responseHeaders),
+ 'headers' => is_array($responseHeaders) ? $responseHeaders : array(),
+ 'body' => $responseBody,
+ );
+ }
+
+ $curl = curl_init();
+ $curlOpts = array(
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HEADER => true,
+ CURLOPT_TIMEOUT => $timeout,
+ CURLOPT_FOLLOWLOCATION => false,
+ CURLOPT_MAXREDIRS => 0,
+ CURLOPT_CUSTOMREQUEST => $method,
+ );
+ if ($headers) {
+ $curlOpts[CURLOPT_HTTPHEADER] = $headers;
+ }
+ if ($body !== null) {
+ $curlOpts[CURLOPT_POSTFIELDS] = $body;
+ }
+ curl_setopt_array($curl, $curlOpts);
+ $raw = curl_exec($curl);
+ $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
+ $headerSize = (int)curl_getinfo($curl, CURLINFO_HEADER_SIZE);
+ curl_close($curl);
+ if ($raw === false || $raw === null) {
+ return array('status' => $status, 'headers' => array(), 'body' => '');
+ }
+ $rawHeaders = substr($raw, 0, $headerSize);
+ $responseBody = substr($raw, $headerSize);
+ $headerLines = preg_split("/\r\n|\n|\r/", trim((string)$rawHeaders));
+ if (!is_array($headerLines)) {
+ $headerLines = array();
+ }
+ return array(
+ 'status' => $status,
+ 'headers' => $headerLines,
+ 'body' => $responseBody === false ? '' : $responseBody,
+ );
+ }
+
+ /**
+ * @return string[]
+ */
+ private function lastHttpHeaders()
+ {
+ if (function_exists('http_get_last_response_headers')) {
+ // @see https://wiki.php.net/rfc/deprecations_php_8_5#deprecate_the_http_response_header_predefined_variable
+ $fromFn = http_get_last_response_headers();
+ if (is_array($fromFn)) {
+ return $fromFn;
}
- $code = $this->getHttpCode($responseHeaders);
+ return array();
+ }
+ if (\PHP_VERSION_ID < 80500) {
+ $headers = require __DIR__ . '/ZBLegacyHttpResponseHeaders.php';
+ return is_array($headers) ? $headers : array();
+ }
+ return array();
+ }
+
+ protected function request($url, $response)
+ {
+ try {
+ $result = $this->executeHttp('GET', $url, array(), null, 120);
+ $json = $this->readJsonBody($result);
$response->Deserialize($json);
- return $code;
+ return (int)$result['status'];
} catch (Exception $e) {
throw new ZBException($this->redactApiKey($e->getMessage()));
}
}
+ /**
+ * @param mixed $value
+ * @return bool
+ */
+ protected function isProvided($value)
+ {
+ return $value !== null && $value !== '';
+ }
+
+ /**
+ * @param mixed $value
+ * @param string $message
+ * @throws ZBMissingParameterException
+ */
+ protected function requireProvided($value, $message)
+ {
+ if (!$this->isProvided($value)) {
+ throw new ZBMissingParameterException($message);
+ }
+ }
+
+ /**
+ * @param int|string|null $timeout
+ * @param bool $batch
+ * @throws ZBException
+ */
+ protected function checkTimeout($timeout, $batch)
+ {
+ if ($timeout === null || $timeout === '') {
+ return;
+ }
+ $message = $batch
+ ? 'Invalid parameter: timeout (must be between 10 and 120 seconds)'
+ : 'Invalid parameter: timeout (must be between 3 and 60 seconds)';
+ if (!is_numeric($timeout)) {
+ throw new ZBException($message);
+ }
+ $timeout = (int)$timeout;
+ if ($batch) {
+ if ($timeout < 10 || $timeout > 120) {
+ throw new ZBException($message);
+ }
+ return;
+ }
+ if ($timeout < 3 || $timeout > 60) {
+ throw new ZBException($message);
+ }
+ }
+
+ /**
+ * @param array{status?: int, headers?: string[], body?: string} $result
+ * @return string
+ * @throws ZBException
+ */
+ private function readJsonBody(array $result)
+ {
+ $status = isset($result['status']) ? (int)$result['status'] : 0;
+ $body = isset($result['body']) ? (string)$result['body'] : '';
+ $headers = (isset($result['headers']) && is_array($result['headers'])) ? $result['headers'] : array();
+ if (trim($body) === '' || $status > 299 || ZBHttpError::isHtml($body, $headers)) {
+ throw new ZBException(ZBHttpError::message($status, $body, $headers));
+ }
+ $decoded = json_decode($body, true);
+ if (!is_array($decoded)) {
+ throw new ZBException('ZeroBounce returned a response that is not valid JSON.');
+ }
+ ZBHttpError::throwIfBareAccountError($decoded);
+ return $body;
+ }
+
+ /**
+ * @param ZBBatchValidateResponse $response
+ * @throws ZBException
+ */
+ private function throwIfBatchAccountError(ZBBatchValidateResponse $response)
+ {
+ if (!is_array($response->errors)) {
+ return;
+ }
+ foreach ($response->errors as $item) {
+ if (!is_array($item)) {
+ continue;
+ }
+ $address = isset($item['email_address']) ? (string)$item['email_address'] : '';
+ if (strcasecmp($address, 'all') !== 0) {
+ continue;
+ }
+ $message = '';
+ if (isset($item['error']) && is_string($item['error'])) {
+ $message = ZBHttpError::stripPrefix($item['error']);
+ }
+ if ($message === '') {
+ $message = 'Invalid API Key or your account ran out of credits';
+ }
+ throw new ZBException($message);
+ }
+ }
+
+ /**
+ * @param array{body?: string, headers?: string[]} $result
+ * @param string $downloadPath
+ * @return ZBGetFileResponse
+ * @throws ZBException
+ */
+ private function saveBulkDownload(array $result, $downloadPath)
+ {
+ $body = isset($result['body']) ? (string)$result['body'] : '';
+ $headers = (isset($result['headers']) && is_array($result['headers'])) ? $result['headers'] : array();
+ $code = self::parseHttpStatusFromHeaders($headers);
+ $contentType = self::parseContentTypeFromHeaders($headers);
+ if (trim($body) === '' || $code > 299 || ZBHttpError::isHtml($body, $headers) || self::shouldTreatGetFileBodyAsError($body, $contentType)) {
+ throw new ZBException(ZBHttpError::message($code, $body, $headers));
+ }
+ $folder = dirname($downloadPath);
+ if ($folder !== '' && $folder !== '.' && !file_exists($folder)) {
+ mkdir($folder, 0777, true);
+ }
+ if (file_put_contents($downloadPath, $body) === false) {
+ throw new ZBException('Could not write file');
+ }
+ $response = new ZBGetFileResponse();
+ $response->localFilePath = $downloadPath;
+ return $response;
+ }
+
+ /**
+ * @return array
+ */
+ private function multipartFileFields(
+ $emailAddressColumn, $returnUrl, $firstNameColumn, $lastNameColumn,
+ $genderColumn, $ipAddressColumn, $hasHeaderRow, $allowPhase2, $url)
+ {
+ $fields = array(
+ 'api_key' => $this->apiKey,
+ 'email_address_column' => $emailAddressColumn,
+ );
+ if ($returnUrl) {
+ $fields['return_url'] = $returnUrl;
+ }
+ if ($this->isProvided($firstNameColumn)) {
+ $fields['first_name_column'] = $firstNameColumn;
+ }
+ if ($this->isProvided($lastNameColumn)) {
+ $fields['last_name_column'] = $lastNameColumn;
+ }
+ if ($this->isProvided($genderColumn)) {
+ $fields['gender_column'] = $genderColumn;
+ }
+ if ($this->isProvided($ipAddressColumn)) {
+ $fields['ip_address_column'] = $ipAddressColumn;
+ }
+ if ($hasHeaderRow !== null) {
+ $fields['has_header_row'] = $hasHeaderRow ? 'true' : 'false';
+ }
+ if ($allowPhase2 !== null && strpos($url, '/scoring/') === false) {
+ $fields['allow_phase_2'] = $allowPhase2 ? 'true' : 'false';
+ }
+ return $fields;
+ }
+
/**
* @throws ZBMissingApiKeyException
*/
@@ -887,7 +1144,7 @@ public function guessFormat($domain, $firstName, $middleName, $lastName)
$query = http_build_query($args);
- $this->request(self::$ApiBaseUrl . "/guessformat?$query", $response);
+ $this->request($this->apiBaseUrl . "/guessformat?$query", $response);
return $response;
}
@@ -934,7 +1191,7 @@ public function findEmail($domain, $firstName, $companyName = null, $middleName
$query = http_build_query($args);
- $this->request(self::$ApiBaseUrl . "/guessformat?$query", $response);
+ $this->request($this->apiBaseUrl . "/guessformat?$query", $response);
return $response;
}
@@ -970,7 +1227,7 @@ public function findEmailFormat($domain, $companyName = null)
$query = http_build_query($args);
- $this->request(self::$ApiBaseUrl . "/guessformat?$query", $response);
+ $this->request($this->apiBaseUrl . "/guessformat?$query", $response);
return $response;
}
}
diff --git a/src/ZeroBouncePriority3.php b/src/ZeroBouncePriority3.php
new file mode 100644
index 0000000..09317e7
--- /dev/null
+++ b/src/ZeroBouncePriority3.php
@@ -0,0 +1,363 @@
+requireProvided($domainColumn, 'domainColumn is required');
+ $fields = $this->buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow);
+ return $this->sendBulkRootMultipartFile(self::EmailFinderBulkBaseUrl . '/sendfile', $filepath, $fields);
+ }
+
+ public function sendEmailFinderFileStream(
+ $streamContent,
+ $fileName,
+ $domainColumn,
+ $firstNameColumn = null,
+ $lastNameColumn = null,
+ $middleNameColumn = null,
+ $fullNameColumn = null,
+ $hasHeaderRow = null
+ ) {
+ $this->requireProvided($domainColumn, 'domainColumn is required');
+ $fields = $this->buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow);
+ return $this->sendBulkRootMultipartContent(self::EmailFinderBulkBaseUrl . '/sendfile', $fileName, $streamContent, $fields);
+ }
+
+ public function emailFinderFileStatus($fileId)
+ {
+ return $this->bulkRootFileStatus(self::EmailFinderBulkBaseUrl, $fileId);
+ }
+
+ public function emailFinderGetFile($fileId, $downloadPath)
+ {
+ return $this->bulkRootGetFile(self::EmailFinderBulkBaseUrl, $fileId, $downloadPath);
+ }
+
+ public function emailFinderDeleteFile($fileId)
+ {
+ return $this->bulkRootDeleteFile(self::EmailFinderBulkBaseUrl, $fileId);
+ }
+
+ public function sendDomainSearchFile($filepath, $domainColumn, $hasHeaderRow = null)
+ {
+ $this->requireProvided($domainColumn, 'domainColumn is required');
+ $fields = array('domain_column' => $domainColumn);
+ if ($hasHeaderRow !== null) {
+ $fields['has_header_row'] = $hasHeaderRow ? 'true' : 'false';
+ }
+ return $this->sendBulkRootMultipartFile(self::DomainSearchBulkBaseUrl . '/sendfile', $filepath, $fields);
+ }
+
+ public function sendDomainSearchFileStream($streamContent, $fileName, $domainColumn, $hasHeaderRow = null)
+ {
+ $this->requireProvided($domainColumn, 'domainColumn is required');
+ $fields = array('domain_column' => $domainColumn);
+ if ($hasHeaderRow !== null) {
+ $fields['has_header_row'] = $hasHeaderRow ? 'true' : 'false';
+ }
+ return $this->sendBulkRootMultipartContent(self::DomainSearchBulkBaseUrl . '/sendfile', $fileName, $streamContent, $fields);
+ }
+
+ public function domainSearchFileStatus($fileId)
+ {
+ return $this->bulkRootFileStatus(self::DomainSearchBulkBaseUrl, $fileId);
+ }
+
+ public function domainSearchGetFile($fileId, $downloadPath)
+ {
+ return $this->bulkRootGetFile(self::DomainSearchBulkBaseUrl, $fileId, $downloadPath);
+ }
+
+ public function domainSearchDeleteFile($fileId)
+ {
+ return $this->bulkRootDeleteFile(self::DomainSearchBulkBaseUrl, $fileId);
+ }
+
+ public function sendListEvaluatorFile($filepath, $emailAddressColumn)
+ {
+ $this->requireProvided($emailAddressColumn, 'emailAddressColumn is required');
+ $fields = array('email_address_column' => $emailAddressColumn);
+ return $this->sendBulkRootMultipartFile(self::BulkApiBaseUrl . '/listevaluator/', $filepath, $fields);
+ }
+
+ public function sendListEvaluatorFileStream($streamContent, $fileName, $emailAddressColumn)
+ {
+ $this->requireProvided($emailAddressColumn, 'emailAddressColumn is required');
+ $fields = array('email_address_column' => $emailAddressColumn);
+ return $this->sendBulkRootMultipartContent(self::BulkApiBaseUrl . '/listevaluator/', $fileName, $streamContent, $fields);
+ }
+
+ /**
+ * @return array decoded JSON
+ */
+ public function getListEvaluatorResult($fileId)
+ {
+ $this->checkValidApiKey();
+ $this->requireProvided($fileId, 'fileId is required');
+ $url = self::BulkApiBaseUrl . '/listevaluator/' . rawurlencode($fileId) . '/?api_key=' . urlencode($this->apiKey);
+ $holder = new ZBRawJsonResponse();
+ $this->request($url, $holder);
+ return $holder->data;
+ }
+
+ public function sendEvaluatedFile($filepath)
+ {
+ return $this->sendBulkRootMultipartFile(self::BulkApiBaseUrl . '/send-evaluated-file/', $filepath, array());
+ }
+
+ public function sendEvaluatedFileStream($streamContent, $fileName)
+ {
+ return $this->sendBulkRootMultipartContent(self::BulkApiBaseUrl . '/send-evaluated-file/', $fileName, $streamContent, array());
+ }
+
+ /**
+ * @return array
+ */
+ public function addFilter($rule, $target, $value)
+ {
+ $this->checkValidApiKey();
+ if (!$this->isProvided($rule) || !$this->isProvided($target) || !$this->isProvided($value)) {
+ throw new ZBMissingParameterException('rule, target, and value are required');
+ }
+ return $this->filterFormPost($this->apiBaseUrl . '/filters/add', array(
+ 'rule' => $rule,
+ 'target' => $target,
+ 'value' => $value,
+ ));
+ }
+
+ /**
+ * @return array|mixed
+ */
+ public function listFilters($usePost = false)
+ {
+ $this->checkValidApiKey();
+ if ($usePost) {
+ return $this->filterFormPost($this->apiBaseUrl . '/filters/list', array());
+ }
+ $url = $this->apiBaseUrl . '/filters/list?api_key=' . urlencode($this->apiKey);
+ $holder = new ZBRawJsonResponse();
+ $this->request($url, $holder);
+ return $holder->data;
+ }
+
+ /**
+ * @return array
+ */
+ public function deleteFilter($rule, $target, $value)
+ {
+ $this->checkValidApiKey();
+ if (!$this->isProvided($rule) || !$this->isProvided($target) || !$this->isProvided($value)) {
+ throw new ZBMissingParameterException('rule, target, and value are required');
+ }
+ return $this->filterFormPost($this->apiBaseUrl . '/filters/delete', array(
+ 'rule' => $rule,
+ 'target' => $target,
+ 'value' => $value,
+ ));
+ }
+
+ /**
+ * @return array>
+ */
+ public function listS3Files($bucket = null, $search = null)
+ {
+ $this->checkValidApiKey();
+ $url = self::BulkApiRootUrl . '/listS3files?api_key=' . urlencode($this->apiKey);
+ if ($bucket !== null && $bucket !== '') {
+ $url .= '&bucket=' . urlencode($bucket);
+ }
+ if ($search !== null && $search !== '') {
+ $url .= '&search=' . urlencode($search);
+ }
+ $holder = new ZBRawJsonResponse();
+ $this->request($url, $holder);
+ return $holder->data;
+ }
+
+ public function validateS3File(array $options)
+ {
+ if (!isset($options['file_name']) || !$this->isProvided($options['file_name'])) {
+ throw new ZBMissingParameterException('file_name is required');
+ }
+ return $this->postBulkRootForm('/validateS3file', $this->buildValidateS3Fields($options));
+ }
+
+ public function scoreS3File(array $options)
+ {
+ if (!isset($options['file_name']) || !$this->isProvided($options['file_name'])) {
+ throw new ZBMissingParameterException('file_name is required');
+ }
+ $fields = array('file_name' => $options['file_name']);
+ $this->appendOptionalBool($fields, 'has_header_row', isset($options['has_header_row']) ? $options['has_header_row'] : null);
+ $this->appendOptionalBool($fields, 'remove_duplicates', isset($options['remove_duplicates']) ? $options['remove_duplicates'] : null);
+ $this->appendOptionalBool($fields, 'remove_duplicate', isset($options['remove_duplicate']) ? $options['remove_duplicate'] : null);
+ if (isset($options['email_address_column'])) {
+ $fields['email_address_column'] = $options['email_address_column'];
+ }
+ return $this->postBulkRootForm('/scoreS3file', $fields);
+ }
+
+ public function exportS3File($fileId, $exportBucket)
+ {
+ $this->requireProvided($fileId, 'fileId is required');
+ $this->requireProvided($exportBucket, 'exportBucket is required');
+ return $this->postBulkRootForm('/exportS3file', array(
+ 'file_id' => $fileId,
+ 'export_bucket' => $exportBucket,
+ ));
+ }
+
+ private function buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow)
+ {
+ $fields = array('domain_column' => $domainColumn);
+ if ($hasHeaderRow !== null) {
+ $fields['has_header_row'] = $hasHeaderRow ? 'true' : 'false';
+ }
+ if ($this->isProvided($firstNameColumn)) {
+ $fields['first_name_column'] = $firstNameColumn;
+ }
+ if ($this->isProvided($lastNameColumn)) {
+ $fields['last_name_column'] = $lastNameColumn;
+ }
+ if ($this->isProvided($middleNameColumn)) {
+ $fields['middle_name_column'] = $middleNameColumn;
+ }
+ if ($this->isProvided($fullNameColumn)) {
+ $fields['full_name_column'] = $fullNameColumn;
+ }
+ return $fields;
+ }
+
+ private function buildValidateS3Fields(array $options)
+ {
+ $fields = array('file_name' => $options['file_name']);
+ $this->appendOptionalBool($fields, 'has_header_row', isset($options['has_header_row']) ? $options['has_header_row'] : null);
+ $this->appendOptionalBool($fields, 'remove_duplicates', isset($options['remove_duplicates']) ? $options['remove_duplicates'] : null);
+ $this->appendOptionalBool($fields, 'remove_duplicate', isset($options['remove_duplicate']) ? $options['remove_duplicate'] : null);
+ if (isset($options['email_address_column'])) {
+ $fields['email_address_column'] = $options['email_address_column'];
+ }
+ if (!empty($options['return_url'])) {
+ $fields['return_url'] = $options['return_url'];
+ }
+ if (isset($options['first_name_column']) && $this->isProvided($options['first_name_column'])) {
+ $fields['first_name_column'] = $options['first_name_column'];
+ }
+ if (isset($options['last_name_column']) && $this->isProvided($options['last_name_column'])) {
+ $fields['last_name_column'] = $options['last_name_column'];
+ }
+ if (isset($options['gender_column']) && $this->isProvided($options['gender_column'])) {
+ $fields['gender_column'] = $options['gender_column'];
+ }
+ if (isset($options['ip_address_column']) && $this->isProvided($options['ip_address_column'])) {
+ $fields['ip_address_column'] = $options['ip_address_column'];
+ }
+ if (isset($options['allow_phase_2'])) {
+ $fields['allow_phase_2'] = $options['allow_phase_2'] ? 'true' : 'false';
+ }
+ return $fields;
+ }
+
+ private function appendOptionalBool(array &$fields, $key, $value)
+ {
+ if ($value !== null) {
+ $fields[$key] = $value ? 'true' : 'false';
+ }
+ }
+
+ private function sendBulkRootMultipartFile($url, $filepath, array $fields)
+ {
+ $this->checkValidApiKey();
+ if (!$filepath) {
+ throw new ZBMissingParameterException('filePath is required');
+ }
+ $fields['api_key'] = $this->apiKey;
+ $files = array($filepath => file_get_contents($filepath));
+ $response = $this->curl($url, $fields, $files);
+ $rsp = new ZBSendFileResponse();
+ $rsp->Deserialize($response);
+ return $rsp;
+ }
+
+ private function sendBulkRootMultipartContent($url, $fileName, $content, array $fields)
+ {
+ $this->checkValidApiKey();
+ $fields['api_key'] = $this->apiKey;
+ $files = array($fileName => $content);
+ $response = $this->curl($url, $fields, $files);
+ $rsp = new ZBSendFileResponse();
+ $rsp->Deserialize($response);
+ return $rsp;
+ }
+
+ private function postBulkRootForm($path, array $fields)
+ {
+ $this->checkValidApiKey();
+ $rsp = new ZBSendFileResponse();
+ $this->formPost(self::BulkApiRootUrl . $path, $fields, $rsp);
+ return $rsp;
+ }
+
+ private function bulkRootFileStatus($baseUrl, $fileId)
+ {
+ $this->checkValidApiKey();
+ $this->requireProvided($fileId, 'fileId is required');
+ $response = new ZBFileStatusResponse();
+ $this->request(
+ $baseUrl . '/filestatus?api_key=' . urlencode($this->apiKey) . '&file_id=' . urlencode($fileId),
+ $response
+ );
+ return $response;
+ }
+
+ private function bulkRootDeleteFile($baseUrl, $fileId)
+ {
+ $this->checkValidApiKey();
+ $this->requireProvided($fileId, 'fileId is required');
+ $response = new ZBDeleteFileResponse();
+ $this->request(
+ $baseUrl . '/deletefile?api_key=' . urlencode($this->apiKey) . '&file_id=' . urlencode($fileId),
+ $response
+ );
+ return $response;
+ }
+
+ private function bulkRootGetFile($baseUrl, $fileId, $downloadPath)
+ {
+ $this->checkValidApiKey();
+ $this->requireProvided($fileId, 'fileId is required');
+ $this->requireProvided($downloadPath, 'downloadPath is required');
+ $url = $baseUrl . '/getfile?api_key=' . urlencode($this->apiKey) . '&file_id=' . urlencode($fileId);
+ return $this->saveBulkDownload($this->getBulkFileHttp($url), $downloadPath);
+ }
+
+ /**
+ * @return array
+ */
+ private function filterFormPost($url, array $data)
+ {
+ $holder = new ZBRawJsonResponse();
+ $this->formPost($url, $data, $holder);
+ return $holder->data;
+ }
+}
diff --git a/test/MockZeroBounce.php b/test/MockZeroBounce.php
index c94d69f..bf926ac 100644
--- a/test/MockZeroBounce.php
+++ b/test/MockZeroBounce.php
@@ -49,6 +49,18 @@ public static function Instance()
*/
public $lastJsonUrl;
+ /**
+ * Last form POST URL passed to formPost().
+ * @var string|null
+ */
+ public $lastFormPostUrl;
+
+ /**
+ * Last GET URL passed to request().
+ * @var string|null
+ */
+ public $lastRequestUrl;
+
/**
* Overwrites the HTTP JSON POST request made within the ZeroBounce class
* @param string $url
@@ -76,6 +88,7 @@ protected function json($url, $data, $response)
*/
protected function request($url, $response)
{
+ $this->lastRequestUrl = $url;
try {
$code = 200;
$response->Deserialize($this->responseText);
@@ -85,6 +98,24 @@ protected function request($url, $response)
}
}
+ /**
+ * @param string $url
+ * @param array $data
+ * @param ZBResponse $response
+ * @return int
+ * @throws ZBException
+ */
+ protected function formPost($url, $data, $response)
+ {
+ $this->lastFormPostUrl = $url;
+ try {
+ $response->Deserialize($this->responseText);
+ return 200;
+ } catch (Exception $e) {
+ throw new ZBException($e->getMessage());
+ }
+ }
+
/**
* Overwrites the curl request made within the ZeroBounce class
* @param string $url
@@ -92,8 +123,14 @@ protected function request($url, $response)
* @param array $files
* @return string
*/
+ /**
+ * @var string|null
+ */
+ public $lastCurlUrl;
+
protected function curl($url, $fields, $files)
{
+ $this->lastCurlUrl = $url;
$this->lastMultipartFields = $fields;
return $this->responseText;
}
diff --git a/test/OfflineApiTest.php b/test/OfflineApiTest.php
new file mode 100644
index 0000000..033266d
--- /dev/null
+++ b/test/OfflineApiTest.php
@@ -0,0 +1,986 @@
+csv = __DIR__ . '/email_file.csv';
+ }
+
+ protected function tearDown(): void
+ {
+ $ref = new ReflectionClass(ZeroBounce::class);
+ if ($ref->hasProperty('ApiBaseUrl')) {
+ $prop = $ref->getProperty('ApiBaseUrl');
+ if (\PHP_VERSION_ID < 80100) {
+ $prop->setAccessible(true);
+ }
+ if ($prop->isStatic()) {
+ $prop->setValue(null, ZBBaseUrl::API_DEFAULT_URL);
+ }
+ }
+ }
+
+ public function testGetCreditsReachesEndpoint()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'credits-key');
+ $response = $client->getCredits();
+ $this->assertSame('7', (string)$response->credits);
+ $request = $this->only($api);
+ $this->assertSame('GET', $request['method']);
+ $this->assertSame('api.zerobounce.net', $request['host']);
+ $this->assertSame('/v2/getcredits', $request['path']);
+ $this->assertSame('credits-key', $request['api_key']);
+ $this->assertSame(120, $request['timeout']);
+ $this->assertNoUserAgentOrMatchingVersion($request);
+ }
+
+ public function testValidateOmitsUnsetOptionsAndEncodesFalse()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'validate-key');
+ $options = new ZBValidateOptions();
+ $options->activityData = false;
+ $options->verifyPlus = true;
+ $response = $client->validate('person@example.com', '', $options);
+ $this->assertSame('validate-key', $response->address);
+ $request = $this->only($api);
+ $this->assertSame('GET', $request['method']);
+ $this->assertSame('/v2/validate', $request['path']);
+ $this->assertSame('api.zerobounce.net', $request['host']);
+ $this->assertSame('person@example.com', $request['fields']['email']);
+ $this->assertSame('', $request['fields']['ip_address']);
+ $this->assertSame('false', $request['fields']['activity_data']);
+ $this->assertSame('true', $request['fields']['verify_plus']);
+ $this->assertArrayNotHasKey('timeout', $request['fields']);
+ }
+
+ public function testValidatePostFormTimeoutAndZeroIp()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'post-key');
+ $options = new ZBValidateOptions();
+ $options->usePost = true;
+ $options->timeout = 15;
+ $client->validate('person@example.com', '0', $options);
+ $request = $this->only($api);
+ $this->assertSame('POST', $request['method']);
+ $this->assertSame('api.zerobounce.net', $request['host']);
+ $this->assertSame('/v2/validate', $request['path']);
+ $this->assertSame('application/x-www-form-urlencoded', $request['content_type']);
+ $this->assertSame('post-key', $request['fields']['api_key']);
+ $this->assertSame('person@example.com', $request['fields']['email']);
+ $this->assertSame('0', $request['fields']['ip_address']);
+ $this->assertSame('15', $request['fields']['timeout']);
+ $this->assertArrayNotHasKey('activity_data', $request['fields']);
+ $this->assertArrayNotHasKey('verify_plus', $request['fields']);
+ }
+
+ public function testValidateEncodesQueryValues()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'encode-key');
+ $client->validate('a+b c@example.com', '203.0.113.5');
+ $request = $this->only($api);
+ $this->assertSame('a+b c@example.com', $request['fields']['email']);
+ $this->assertStringContainsString('email=a%2Bb+c%40example.com', $request['url']);
+ $this->assertStringNotContainsString('a+b c@example.com', $request['url']);
+ }
+
+ public function testValidateBatchUsesApiHostAndOmitsUnsetOptions()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'batch-key');
+ $response = $client->validateBatch(array(
+ array('a@example.com', ''),
+ array('b@example.com', '203.0.113.9'),
+ ));
+ $this->assertSame('batch-key', $response->emailBatch[0]->address);
+ $request = $this->only($api);
+ $this->assertSame('POST', $request['method']);
+ $this->assertSame('api.zerobounce.net', $request['host']);
+ $this->assertSame('/v2/validatebatch', $request['path']);
+ $this->assertStringNotContainsString('bulkapi', $request['url']);
+ $this->assertSame('batch-key', $request['fields']['api_key']);
+ $this->assertStringContainsString('"email_address":"a@example.com"', $request['fields']['__json']);
+ $this->assertStringContainsString('"ip_address":""', $request['fields']['__json']);
+ $this->assertStringContainsString('"ip_address":"203.0.113.9"', $request['fields']['__json']);
+ $this->assertArrayNotHasKey('timeout', $request['fields']);
+ $this->assertArrayNotHasKey('activity_data', $request['fields']);
+ $this->assertArrayNotHasKey('verify_plus', $request['fields']);
+
+ $api->clear();
+ $client->validateBatch(array('only@example.com'));
+ $strings = $this->only($api);
+ $this->assertStringContainsString('"email_address":"only@example.com"', $strings['fields']['__json']);
+ $this->assertStringNotContainsString('ip_address', $strings['fields']['__json']);
+ }
+
+ public function testValidateBatchSendsExplicitOptions()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'batch-opt');
+ $options = new ZBValidateOptions();
+ $options->timeout = 30;
+ $options->activityData = false;
+ $options->verifyPlus = true;
+ $client->validateBatch(array('a@example.com'), $options);
+ $request = $this->only($api);
+ $this->assertStringContainsString('"timeout":30', $request['fields']['__json']);
+ $this->assertStringContainsString('"activity_data":false', $request['fields']['__json']);
+ $this->assertStringContainsString('"verify_plus":true', $request['fields']['__json']);
+ $this->assertStringNotContainsString('"activity_data":"false"', $request['fields']['__json']);
+ }
+
+ public function testSendFileEncodesBooleansAndKeepsZeroColumn()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'file-key');
+ $response = $client->sendFile(
+ $this->csv,
+ 0,
+ 'https://example.com/hook',
+ 0,
+ null,
+ null,
+ null,
+ false,
+ false
+ );
+ $this->assertSame('file-key', $response->fileId);
+ $request = $this->only($api);
+ $this->assertSame('POST', $request['method']);
+ $this->assertSame('bulkapi.zerobounce.net', $request['host']);
+ $this->assertSame('/v2/sendfile', $request['path']);
+ $this->assertSame('file-key', $request['fields']['api_key']);
+ $this->assertSame('0', (string)$request['fields']['email_address_column']);
+ $this->assertSame('0', (string)$request['fields']['first_name_column']);
+ $this->assertSame('', $request['fields']['file']);
+ $this->assertSame('false', $request['fields']['has_header_row']);
+ $this->assertSame('false', $request['fields']['allow_phase_2']);
+ $this->assertSame('https://example.com/hook', $request['fields']['return_url']);
+ $this->assertArrayNotHasKey('last_name_column', $request['fields']);
+ $this->assertArrayNotHasKey('gender_column', $request['fields']);
+ $this->assertArrayNotHasKey('ip_address_column', $request['fields']);
+ $this->assertStringNotContainsString('1', $request['fields']['has_header_row']);
+ }
+
+ public function testScoringSendFileOmitsValidationOnlyFields()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'score-file');
+ $client->scoringSendFile($this->csv, 1, null, false);
+ $request = $this->only($api);
+ $this->assertSame('/v2/scoring/sendfile', $request['path']);
+ $this->assertSame('bulkapi.zerobounce.net', $request['host']);
+ $this->assertSame('1', (string)$request['fields']['email_address_column']);
+ $this->assertSame('false', $request['fields']['has_header_row']);
+ $this->assertArrayNotHasKey('allow_phase_2', $request['fields']);
+ $this->assertArrayNotHasKey('first_name_column', $request['fields']);
+
+ $api->clear();
+ $client->scoringSendFileStream("a@b.com\n", 'scores.csv', 1, null, null);
+ $stream = $this->only($api);
+ $this->assertArrayNotHasKey('has_header_row', $stream['fields']);
+ $this->assertSame('', $stream['fields']['file']);
+ $this->assertSame('scores.csv', $stream['fields']['__filename']);
+ }
+
+ public function testSendFileStreamKeepsZeroColumn()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'stream-key');
+ $client->sendFileStream("a@b.com\n", 'emails.csv', 0, null, null, null, null, null, true, null);
+ $request = $this->only($api);
+ $this->assertSame('/v2/sendfile', $request['path']);
+ $this->assertSame('0', (string)$request['fields']['email_address_column']);
+ $this->assertSame('true', $request['fields']['has_header_row']);
+ $this->assertArrayNotHasKey('allow_phase_2', $request['fields']);
+ }
+
+ public function testFileStatusAndDeleteUseBulkHost()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'status-key');
+ $status = $client->fileStatus('file 1');
+ $this->assertSame('status-key', $status->fileId);
+ $request = $this->only($api);
+ $this->assertSame('/v2/filestatus', $request['path']);
+ $this->assertSame('bulkapi.zerobounce.net', $request['host']);
+ $this->assertSame('file 1', $request['fields']['file_id']);
+ $this->assertStringContainsString('file_id=file+1', $request['url']);
+
+ $api->clear();
+ $client->scoringFileStatus('file-2');
+ $this->assertSame('/v2/scoring/filestatus', $this->only($api)['path']);
+
+ $api->clear();
+ $client->deleteFile('file-1');
+ $this->assertSame('/v2/deletefile', $this->only($api)['path']);
+
+ $api->clear();
+ $client->scoringDeleteFile('file-2');
+ $this->assertSame('/v2/scoring/deletefile', $this->only($api)['path']);
+ }
+
+ public function testGetFileWithOptionsParameterOrder()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'getfile-key');
+ $path = $this->tempPath('offline-get.csv');
+ $options = new ZBGetFileOptions();
+ $options->downloadType = ZBDownloadType::PHASE_2;
+ $options->activityData = false;
+ $response = $client->getFile('file-9', $path, $options);
+ $body = file_get_contents($response->localFilePath);
+ $this->assertStringContainsString('getfile-key@example.com', $body);
+ $this->assertStringNotContainsString('only($api);
+ $this->assertSame('/v2/getfile', $request['path']);
+ $this->assertSame('bulkapi.zerobounce.net', $request['host']);
+ $this->assertSame('file-9', $request['fields']['file_id']);
+ $this->assertSame('phase_2', $request['fields']['download_type']);
+ $this->assertSame('false', $request['fields']['activity_data']);
+ @unlink($path);
+
+ $api->clear();
+ $scorePath = $this->tempPath('offline-score.csv');
+ $scoring = new ZBGetFileOptions();
+ $scoring->downloadType = ZBDownloadType::PHASE_1;
+ $scoring->activityData = true;
+ $client->scoringGetFile('file-8', $scorePath, $scoring);
+ $scoringRequest = $this->only($api);
+ $this->assertSame('/v2/scoring/getfile', $scoringRequest['path']);
+ $this->assertSame('phase_1', $scoringRequest['fields']['download_type']);
+ $this->assertArrayNotHasKey('activity_data', $scoringRequest['fields']);
+ @unlink($scorePath);
+ }
+
+ public function testActivityUsageAndScoreEndpoints()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'usage-key');
+ $usage = $client->getApiUsage(new DateTime('2018-01-01'), new DateTime('2018-01-31'));
+ $this->assertSame(1, (int)$usage->total);
+ $request = $this->only($api);
+ $this->assertSame('/v2/getapiusage', $request['path']);
+ $this->assertSame('2018-01-01', $request['fields']['start_date']);
+ $this->assertSame('2018-01-31', $request['fields']['end_date']);
+
+ $api->clear();
+ $activity = $client->getActivity('person@example.com');
+ $this->assertTrue((bool)$activity->found);
+ $activityRequest = $this->only($api);
+ $this->assertSame('/v2/activity', $activityRequest['path']);
+ $this->assertSame('person@example.com', $activityRequest['fields']['email']);
+
+ $api->clear();
+ $score = $client->scoreEmail('person@example.com');
+ $this->assertSame('usage-key', $score->email);
+ $scoreRequest = $this->only($api);
+ $this->assertSame('GET', $scoreRequest['method']);
+ $this->assertSame('/v2/scoring', $scoreRequest['path']);
+ $this->assertSame('api.zerobounce.net', $scoreRequest['host']);
+
+ $api->clear();
+ $client->scoreEmailPost('person@example.com');
+ $posted = $this->only($api);
+ $this->assertSame('POST', $posted['method']);
+ $this->assertSame('/v2/scoring', $posted['path']);
+ $this->assertSame('person@example.com', $posted['fields']['email']);
+ $this->assertSame('usage-key', $posted['fields']['api_key']);
+ }
+
+ public function testFinderAndDomainSearch()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'finder-key');
+ $byDomain = $client->findEmail('example.com', 'Ada', null, 'M', 'Lovelace');
+ $this->assertSame('finder-key', $byDomain->email);
+ $request = $this->only($api);
+ $this->assertSame('/v2/guessformat', $request['path']);
+ $this->assertSame('example.com', $request['fields']['domain']);
+ $this->assertSame('Ada', $request['fields']['first_name']);
+ $this->assertSame('M', $request['fields']['middle_name']);
+ $this->assertSame('Lovelace', $request['fields']['last_name']);
+ $this->assertArrayNotHasKey('company_name', $request['fields']);
+
+ $api->clear();
+ $client->findEmail(null, 'Ada', 'ZeroBounce');
+ $byCompany = $this->only($api);
+ $this->assertSame('ZeroBounce', $byCompany['fields']['company_name']);
+ $this->assertSame('Ada', $byCompany['fields']['first_name']);
+ $this->assertArrayNotHasKey('domain', $byCompany['fields']);
+ $this->assertArrayNotHasKey('last_name', $byCompany['fields']);
+
+ $api->clear();
+ $format = $client->findEmailFormat('example.com');
+ $this->assertSame('finder-key', $format->domain);
+ $this->assertArrayNotHasKey('company_name', $this->only($api)['fields']);
+
+ $api->clear();
+ $client->findEmailFormat(null, 'ZeroBounce');
+ $company = $this->only($api);
+ $this->assertSame('ZeroBounce', $company['fields']['company_name']);
+ $this->assertArrayNotHasKey('domain', $company['fields']);
+
+ }
+
+ /**
+ * @testdox guessFormat still calls /v2/guessformat
+ */
+ #[\PHPUnit\Framework\Attributes\IgnoreDeprecations]
+ public function testGuessFormatStillReachesTheEndpoint()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'finder-key');
+ $client->guessFormat('example.com', 'Ada', null, null);
+ $guess = $this->only($api);
+ $this->assertSame('/v2/guessformat', $guess['path']);
+ $this->assertSame('Ada', $guess['fields']['first_name']);
+ $this->assertArrayNotHasKey('middle_name', $guess['fields']);
+ $this->assertArrayNotHasKey('last_name', $guess['fields']);
+ }
+
+ public function testPriority3EndpointsAndZeroColumn()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'p3-key');
+ $client->sendEmailFinderFile($this->csv, 0, 0, null, null, null, false);
+ $sent = $this->only($api);
+ $this->assertSame('bulkapi.zerobounce.net', $sent['host']);
+ $this->assertSame('/email-finder/sendfile', $sent['path']);
+ $this->assertSame('0', (string)$sent['fields']['domain_column']);
+ $this->assertSame('0', (string)$sent['fields']['first_name_column']);
+ $this->assertSame('false', $sent['fields']['has_header_row']);
+ $this->assertArrayNotHasKey('last_name_column', $sent['fields']);
+ $this->assertSame('p3-key', $sent['fields']['api_key']);
+
+ $api->clear();
+ $client->sendEmailFinderFileStream("acme.com\n", 'finder.csv', 1, null, null, null, null, null);
+ $finderStream = $this->only($api);
+ $this->assertSame('/email-finder/sendfile', $finderStream['path']);
+ $this->assertArrayNotHasKey('has_header_row', $finderStream['fields']);
+ $this->assertSame('finder.csv', $finderStream['fields']['__filename']);
+
+ $api->clear();
+ $client->emailFinderFileStatus('fid');
+ $this->assertSame('/email-finder/filestatus', $this->only($api)['path']);
+ $api->clear();
+ $finderPath = $this->tempPath('finder.csv');
+ $client->emailFinderGetFile('fid', $finderPath);
+ $this->assertSame('/email-finder/getfile', $this->only($api)['path']);
+ $this->assertStringContainsString('p3-key@example.com', file_get_contents($finderPath));
+ @unlink($finderPath);
+ $api->clear();
+ $client->emailFinderDeleteFile('fid');
+ $this->assertSame('/email-finder/deletefile', $this->only($api)['path']);
+
+ $api->clear();
+ $client->sendDomainSearchFile($this->csv, 2, false);
+ $domain = $this->only($api);
+ $this->assertSame('/domain-search/sendfile', $domain['path']);
+ $this->assertSame('2', (string)$domain['fields']['domain_column']);
+ $this->assertSame('false', $domain['fields']['has_header_row']);
+
+ $api->clear();
+ $client->sendDomainSearchFileStream("acme.com\n", 'domains.csv', 1, null);
+ $this->assertSame('/domain-search/sendfile', $this->only($api)['path']);
+ $api->clear();
+ $client->domainSearchFileStatus('fid');
+ $this->assertSame('/domain-search/filestatus', $this->only($api)['path']);
+ $api->clear();
+ $domainPath = $this->tempPath('domain.csv');
+ $client->domainSearchGetFile('fid', $domainPath);
+ $this->assertSame('/domain-search/getfile', $this->only($api)['path']);
+ @unlink($domainPath);
+ $api->clear();
+ $client->domainSearchDeleteFile('fid');
+ $this->assertSame('/domain-search/deletefile', $this->only($api)['path']);
+
+ $api->clear();
+ $client->sendListEvaluatorFile($this->csv, 0);
+ $evaluator = $this->only($api);
+ $this->assertSame('/v2/listevaluator/', $evaluator['path']);
+ $this->assertSame('0', (string)$evaluator['fields']['email_address_column']);
+
+ $api->clear();
+ $client->sendListEvaluatorFileStream("a@b.com\n", 'list.csv', 1);
+ $this->assertSame('/v2/listevaluator/', $this->only($api)['path']);
+
+ $api->clear();
+ $raw = $client->getListEvaluatorResult('a/b');
+ $this->assertSame('p3-key', $raw['echo_api_key']);
+ $result = $this->only($api);
+ $this->assertSame('/v2/listevaluator/a%2Fb/', $result['path']);
+ $this->assertSame('p3-key', $result['fields']['api_key']);
+
+ $api->clear();
+ $client->sendEvaluatedFile($this->csv);
+ $this->assertSame('/v2/send-evaluated-file/', $this->only($api)['path']);
+ $api->clear();
+ $client->sendEvaluatedFileStream("a@b.com\n", 'evaluated.csv');
+ $evaluated = $this->only($api);
+ $this->assertSame('/v2/send-evaluated-file/', $evaluated['path']);
+ $this->assertSame('evaluated.csv', $evaluated['fields']['__filename']);
+
+ $api->clear();
+ $added = $client->addFilter('exclude', 'domain', '0');
+ $this->assertSame('p3-key', $added['echo_api_key']);
+ $add = $this->only($api);
+ $this->assertSame('POST', $add['method']);
+ $this->assertSame('api.zerobounce.net', $add['host']);
+ $this->assertSame('/v2/filters/add', $add['path']);
+ $this->assertSame('exclude', $add['fields']['rule']);
+ $this->assertSame('domain', $add['fields']['target']);
+ $this->assertSame('0', $add['fields']['value']);
+
+ $api->clear();
+ $listed = $client->listFilters(false);
+ $this->assertSame('p3-key', $listed['echo_api_key']);
+ $list = $this->only($api);
+ $this->assertSame('GET', $list['method']);
+ $this->assertSame('/v2/filters/list', $list['path']);
+
+ $api->clear();
+ $client->listFilters(true);
+ $this->assertSame('POST', $this->only($api)['method']);
+
+ $api->clear();
+ $client->deleteFilter('exclude', 'domain', 'example.com');
+ $this->assertSame('/v2/filters/delete', $this->only($api)['path']);
+
+ $api->clear();
+ $client->validateS3File(array(
+ 'file_name' => 'inbox.csv',
+ 'email_address_column' => 0,
+ 'has_header_row' => false,
+ 'remove_duplicate' => true,
+ 'first_name_column' => 0,
+ ));
+ $validated = $this->only($api);
+ $this->assertSame('POST', $validated['method']);
+ $this->assertSame('/validateS3file', $validated['path']);
+ $this->assertSame('bulkapi.zerobounce.net', $validated['host']);
+ $this->assertSame('inbox.csv', $validated['fields']['file_name']);
+ $this->assertSame('0', (string)$validated['fields']['email_address_column']);
+ $this->assertSame('false', $validated['fields']['has_header_row']);
+ $this->assertSame('true', $validated['fields']['remove_duplicate']);
+ $this->assertSame('0', (string)$validated['fields']['first_name_column']);
+ $this->assertArrayNotHasKey('remove_duplicates', $validated['fields']);
+ $this->assertArrayNotHasKey('allow_phase_2', $validated['fields']);
+
+ $api->clear();
+ $client->scoreS3File(array('file_name' => 'inbox.csv'));
+ $scored = $this->only($api);
+ $this->assertSame('/scoreS3file', $scored['path']);
+ $this->assertArrayNotHasKey('email_address_column', $scored['fields']);
+ $this->assertArrayNotHasKey('has_header_row', $scored['fields']);
+
+ $api->clear();
+ $client->exportS3File('fid', 'bucket-out');
+ $exported = $this->only($api);
+ $this->assertSame('/exportS3file', $exported['path']);
+ $this->assertSame('fid', $exported['fields']['file_id']);
+ $this->assertSame('bucket-out', $exported['fields']['export_bucket']);
+
+ $api->clear();
+ $files = $client->listS3Files();
+ $this->assertSame('p3-key', $files[0]['file_name']);
+ $listedFiles = $this->only($api);
+ $this->assertSame('/listS3files', $listedFiles['path']);
+ $this->assertArrayNotHasKey('bucket', $listedFiles['fields']);
+ $this->assertArrayNotHasKey('search', $listedFiles['fields']);
+
+ $api->clear();
+ $client->listS3Files('inbox', 'csv');
+ $filtered = $this->only($api);
+ $this->assertSame('inbox', $filtered['fields']['bucket']);
+ $this->assertSame('csv', $filtered['fields']['search']);
+ }
+
+ public function testRejectsMissingKeyAndInvalidArgumentsBeforeRequest()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, '');
+ $reject = array(
+ function () use ($client) {
+ $client->getCredits();
+ },
+ function () use ($client) {
+ $client->findEmail('example.com', 'Ada');
+ },
+ function () use ($client) {
+ $client->findEmailFormat('example.com');
+ },
+ function () use ($client) {
+ $client->listFilters(false);
+ },
+ function () use ($client) {
+ $client->emailFinderDeleteFile('fid');
+ },
+ function () use ($client) {
+ $client->sendDomainSearchFile($this->csv, 1);
+ },
+ function () use ($client) {
+ $client->validateS3File(array('file_name' => 'inbox.csv'));
+ },
+ function () use ($client) {
+ $client->scoreS3File(array('file_name' => 'inbox.csv'));
+ },
+ function () use ($client) {
+ $client->exportS3File('fid', 'bucket');
+ },
+ function () use ($client) {
+ $client->sendEmailFinderFile($this->csv, 1);
+ },
+ function () use ($client) {
+ $client->addFilter('exclude', 'domain', 'example.com');
+ },
+ );
+ foreach ($reject as $call) {
+ try {
+ $call();
+ $this->fail('empty API key was accepted');
+ } catch (ZBMissingApiKeyException $e) {
+ $this->assertStringContainsString('initialize', $e->getMessage());
+ }
+ }
+ $this->assertSame(0, $api->count());
+
+ $keyed = $this->client($api, 'keyed');
+ $this->assertRejected(function () use ($keyed) {
+ $options = new ZBValidateOptions();
+ $options->timeout = 2;
+ $keyed->validate('a@example.com', '', $options);
+ }, '3 and 60');
+ $this->assertRejected(function () use ($keyed) {
+ $options = new ZBValidateOptions();
+ $options->timeout = 9;
+ $keyed->validateBatch(array('a@example.com'), $options);
+ }, '10 and 120');
+ $this->assertRejected(function () use ($keyed) {
+ $keyed->validate('', null);
+ }, 'email');
+ $this->assertRejected(function () use ($keyed) {
+ $keyed->validateBatch(array());
+ }, 'emails');
+ $this->assertRejected(function () use ($keyed) {
+ $keyed->sendEmailFinderFile($this->csv, null);
+ }, 'domainColumn');
+ $this->assertRejected(function () use ($keyed) {
+ $keyed->sendDomainSearchFile($this->csv, '');
+ }, 'domainColumn');
+ $this->assertRejected(function () use ($keyed) {
+ $keyed->validateS3File(array());
+ }, 'file_name');
+ $this->assertRejected(function () use ($keyed) {
+ $keyed->sendFile('', 1);
+ }, 'filePath');
+ $this->assertRejected(function () use ($keyed) {
+ $keyed->fileStatus('');
+ }, 'fileId');
+ $this->assertRejected(function () use ($keyed) {
+ $keyed->getFile('fid', '');
+ }, 'downloadPath');
+ $this->assertRejected(function () use ($keyed) {
+ $keyed->addFilter('', 'domain', 'example.com');
+ }, 'rule');
+ $this->assertSame(0, $api->count());
+ }
+
+ public function testHtml403IsAShortError()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'html-403-user');
+ $error = $this->capture(function () use ($client) {
+ $client->getCredits();
+ });
+ $this->assertStringContainsString('Access denied', $error->getMessage());
+ $this->assertGreaterThan(100000, strlen(FakeApi::accessDeniedPage()));
+ $this->assertSame(1, $api->count());
+
+ $api->clear();
+ $posted = $this->client($api, 'html-403-post');
+ $postError = $this->capture(function () use ($posted) {
+ $posted->scoreEmailPost('a@example.com');
+ });
+ $this->assertStringContainsString('Access denied', $postError->getMessage());
+
+ $api->clear();
+ $fileClient = $this->client($api, 'html-403-file');
+ $path = $this->tempPath('denied.csv');
+ $fileError = $this->capture(function () use ($fileClient, $path) {
+ $fileClient->sendFile($this->csv, 1);
+ });
+ $this->assertStringContainsString('Access denied', $fileError->getMessage());
+ $this->assertFileDoesNotExist($path);
+ }
+
+ public function testHtml200OnValidateIsAnError()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'html-200-user');
+ $error = $this->capture(function () use ($client) {
+ $client->validate('a@example.com', '');
+ });
+ $this->assertStringContainsString('Access denied', $error->getMessage());
+ }
+
+ public function testHtml200GetFileIsNotSavedAsCsv()
+ {
+ $api = new FakeApi();
+ $paths = array(
+ 'getFile' => $this->tempPath('offline-html.csv'),
+ 'emailFinderGetFile' => $this->tempPath('finder-html.csv'),
+ 'domainSearchGetFile' => $this->tempPath('domain-html.csv'),
+ );
+ $client = $this->client($api, 'html-200-file');
+ $error = $this->capture(function () use ($client, $paths) {
+ $client->getFile('fid', $paths['getFile']);
+ });
+ $this->assertStringContainsString('Access denied', $error->getMessage());
+ $this->assertFileDoesNotExist($paths['getFile']);
+
+ $finder = $this->capture(function () use ($client, $paths) {
+ $client->emailFinderGetFile('fid', $paths['emailFinderGetFile']);
+ });
+ $this->assertStringContainsString('Access denied', $finder->getMessage());
+ $this->assertFileDoesNotExist($paths['emailFinderGetFile']);
+
+ $domain = $this->capture(function () use ($client, $paths) {
+ $client->domainSearchGetFile('fid', $paths['domainSearchGetFile']);
+ });
+ $this->assertStringContainsString('Access denied', $domain->getMessage());
+ $this->assertFileDoesNotExist($paths['domainSearchGetFile']);
+ }
+
+ public function testCloudflareJson403DoesNotDumpTheBody()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'cf-json-user');
+ $error = $this->capture(function () use ($client) {
+ $client->getCredits();
+ });
+ $this->assertStringNotContainsString('developers.cloudflare.com', $error->getMessage());
+ $this->assertStringContainsString('Access denied', $error->getMessage());
+ }
+
+ public function testEmptyAndMalformedBodiesAreShortErrors()
+ {
+ $api = new FakeApi();
+ $empty = $this->client($api, 'empty-user');
+ $emptyError = $this->capture(function () use ($empty) {
+ $empty->validate('a@example.com', '');
+ });
+ $this->assertStringContainsString('Empty response', $emptyError->getMessage());
+
+ $path = $this->tempPath('empty-file.csv');
+ $emptyFile = $this->capture(function () use ($empty, $path) {
+ $empty->getFile('fid', $path);
+ });
+ $this->assertStringContainsString('Empty response', $emptyFile->getMessage());
+ $this->assertFileDoesNotExist($path);
+
+ $malformed = $this->client($api, 'malformed-user');
+ $malformedError = $this->capture(function () use ($malformed) {
+ $malformed->validate('a@example.com', '');
+ });
+ $this->assertStringContainsString('not valid JSON', $malformedError->getMessage());
+ $this->assertStringNotContainsString('<<>>', $malformedError->getMessage());
+ }
+
+ public function testDoubledErrorPrefixIsCollapsed()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'double-error-user');
+ $error = $this->capture(function () use ($client) {
+ $client->getActivity('a@example.com');
+ });
+ $this->assertSame('api_key is invalid', $error->getMessage());
+ }
+
+ public function testBatchAccountErrorIsNotASuccess()
+ {
+ $api = new FakeApi();
+ $invalid = $this->client($api, 'account-invalid-user');
+ $error = $this->capture(function () use ($invalid) {
+ $invalid->validateBatch(array('a@example.com'));
+ });
+ $this->assertStringContainsString('Invalid API Key', $error->getMessage());
+ $this->assertStringContainsString('ran out of credits', $error->getMessage());
+
+ $credits = $this->client($api, 'account-invalid-credits');
+ $creditError = $this->capture(function () use ($credits) {
+ $credits->getCredits();
+ });
+ $this->assertStringContainsString('Invalid API Key', $creditError->getMessage());
+
+ $none = $this->client($api, 'account-nocredits-user');
+ $noneError = $this->capture(function () use ($none) {
+ $none->validate('a@example.com', '');
+ });
+ $this->assertStringContainsString('ran out of credits', $noneError->getMessage());
+ }
+
+ public function testBatchPartialErrorsStayOnTheSuccessResult()
+ {
+ $api = new FakeApi();
+ $client = $this->client($api, 'batch-partial-user');
+ $response = $client->validateBatch(array('good@example.com', 'bad@'));
+ $this->assertCount(1, $response->emailBatch);
+ $this->assertSame('good@example.com', $response->emailBatch[0]->address);
+ $this->assertCount(1, $response->errors);
+ $this->assertSame('bad@', $response->errors[0]['email_address']);
+ $this->assertSame('Invalid email address', $response->errors[0]['error']);
+ }
+
+ public function testSeparateClientsDoNotShareKeyOrBaseUrl()
+ {
+ $api = new FakeApi();
+ $us = new OfflineClient($api);
+ $eu = new OfflineClient($api);
+ $us->initialize('key-us', ZBBaseUrl::API_USA_URL);
+ $eu->initialize('key-eu', ZBBaseUrl::API_EU_URL);
+ $us->getCredits();
+ $eu->getCredits();
+ $records = $api->take();
+ $this->assertCount(2, $records);
+ $this->assertSame('api-us.zerobounce.net', $records[0]['host']);
+ $this->assertSame('key-us', $records[0]['api_key']);
+ $this->assertSame('api-eu.zerobounce.net', $records[1]['host']);
+ $this->assertSame('key-eu', $records[1]['api_key']);
+ }
+
+ public function testCreateIsNotTheSingleton()
+ {
+ $a = ZeroBounce::create();
+ $b = ZeroBounce::create();
+ $this->assertNotSame($a, $b);
+ $this->assertNotSame($a, ZeroBounce::Instance());
+ }
+
+ public function testConcurrentProcessesDoNotMixKeys()
+ {
+ $php = PHP_BINARY;
+ $autoload = dirname(__DIR__) . '/vendor/autoload.php';
+ $offline = __DIR__ . '/OfflineHttp.php';
+ $script = tempnam(sys_get_temp_dir(), 'zb-conc-');
+ file_put_contents($script, <<<'PHP'
+initialize($key);
+try {
+ $response = $client->validate('person@example.com');
+ $records = $api->take();
+ $sent = isset($records[0]['api_key']) ? $records[0]['api_key'] : '';
+ file_put_contents($out, json_encode(array(
+ 'address' => $response->address,
+ 'api_key' => $sent,
+ )));
+} catch (Throwable $e) {
+ file_put_contents($out, json_encode(array('error' => $e->getMessage())));
+}
+PHP
+ );
+ $processes = array();
+ $outputs = array();
+ for ($i = 0; $i < 8; $i++) {
+ $key = 'conc-' . $i;
+ $out = $this->tempPath('conc-' . $i . '.json');
+ $outputs[$i] = $out;
+ $cmd = escapeshellarg($php) . ' ' . escapeshellarg($script) . ' '
+ . escapeshellarg($autoload) . ' ' . escapeshellarg($offline) . ' '
+ . escapeshellarg($key) . ' ' . escapeshellarg($out);
+ $pipes = array();
+ $processes[$i] = proc_open($cmd, array(
+ 0 => array('pipe', 'r'),
+ 1 => array('pipe', 'w'),
+ 2 => array('pipe', 'w'),
+ ), $pipes);
+ foreach ($pipes as $pipe) {
+ fclose($pipe);
+ }
+ }
+ foreach ($processes as $process) {
+ proc_close($process);
+ }
+ for ($i = 0; $i < 8; $i++) {
+ $this->assertFileExists($outputs[$i]);
+ $payload = json_decode(file_get_contents($outputs[$i]), true);
+ $this->assertIsArray($payload, file_get_contents($outputs[$i]));
+ $this->assertArrayNotHasKey('error', $payload);
+ $this->assertSame('conc-' . $i, $payload['address']);
+ $this->assertSame('conc-' . $i, $payload['api_key']);
+ @unlink($outputs[$i]);
+ }
+ @unlink($script);
+ }
+
+ public function testRedirectsAreNotFollowed()
+ {
+ $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
+ $this->assertNotFalse($socket, $errstr);
+ $name = stream_socket_get_name($socket, false);
+ fclose($socket);
+ $port = (int)substr(strrchr($name, ':'), 1);
+ $router = tempnam(sys_get_temp_dir(), 'zb-router-');
+ file_put_contents($router, 'redirectgo";
+return true;
+');
+ $pipes = array();
+ $process = proc_open(
+ escapeshellarg(PHP_BINARY) . ' -S 127.0.0.1:' . $port . ' ' . escapeshellarg($router),
+ array(0 => array('pipe', 'r'), 1 => array('pipe', 'w'), 2 => array('pipe', 'w')),
+ $pipes
+ );
+ $this->assertIsResource($process);
+ $ready = false;
+ for ($i = 0; $i < 50; $i++) {
+ $connection = @fsockopen('127.0.0.1', $port, $cerrno, $cerr, 0.1);
+ if ($connection) {
+ fclose($connection);
+ $ready = true;
+ break;
+ }
+ usleep(20000);
+ }
+ $this->assertTrue($ready, 'local API did not start');
+ try {
+ $client = new DirectClient();
+ $client->initialize('redirect-key');
+ $this->forceBaseUrl($client, 'http://127.0.0.1:' . $port . '/v2');
+ $error = $this->capture(function () use ($client) {
+ $client->getCredits();
+ });
+ $this->assertStringNotContainsString('999', $error->getMessage());
+ } finally {
+ proc_terminate($process);
+ proc_close($process);
+ foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) {
+ fclose($pipe);
+ }
+ }
+ @unlink($router);
+ }
+ }
+
+ private function client(FakeApi $api, $key)
+ {
+ $client = new OfflineClient($api);
+ $client->initialize($key);
+ return $client;
+ }
+
+ /**
+ * @return array
+ */
+ private function only(FakeApi $api)
+ {
+ $records = $api->take();
+ $this->assertCount(1, $records);
+ return $records[0];
+ }
+
+ private function capture(callable $call)
+ {
+ try {
+ $call();
+ } catch (ZBException $e) {
+ $this->assertShort($e->getMessage());
+ return $e;
+ }
+ $this->fail('expected ZBException');
+ }
+
+ private function assertRejected(callable $call, $needle)
+ {
+ try {
+ $call();
+ } catch (ZBException $e) {
+ $this->assertStringContainsString($needle, $e->getMessage());
+ return;
+ }
+ $this->fail('expected rejection containing ' . $needle);
+ }
+
+ private function assertShort($message)
+ {
+ $this->assertLessThan(500, strlen($message), substr($message, 0, 180));
+ $lower = strtolower($message);
+ $this->assertStringNotContainsString('assertStringNotContainsString('assertStringNotContainsString('blocked
', $message);
+ $this->assertStringNotContainsString('Error: Error:', $message);
+ $this->assertStringNotContainsString('Invalid response "', $message);
+ $this->assertStringNotContainsString('developers.cloudflare.com', $message);
+ }
+
+ private function assertNoUserAgentOrMatchingVersion(array $request)
+ {
+ $version = json_decode(file_get_contents(dirname(__DIR__) . '/composer.json'))->version;
+ foreach ($request['headers'] as $header) {
+ if (stripos($header, 'User-Agent:') === 0) {
+ $this->assertStringContainsString($version, $header);
+ }
+ }
+ }
+
+ private function forceBaseUrl($client, $url)
+ {
+ $ref = new ReflectionClass($client);
+ if ($ref->hasProperty('apiBaseUrl')) {
+ $prop = $ref->getProperty('apiBaseUrl');
+ } else {
+ $prop = $ref->getProperty('ApiBaseUrl');
+ }
+ if (\PHP_VERSION_ID < 80100) {
+ $prop->setAccessible(true);
+ }
+ if ($prop->isStatic()) {
+ $prop->setValue(null, $url);
+ } else {
+ $prop->setValue($client, $url);
+ }
+ }
+
+ private function tempPath($name)
+ {
+ return sys_get_temp_dir() . '/zb-php-' . getmypid() . '-' . $name;
+ }
+}
diff --git a/test/OfflineHttp.php b/test/OfflineHttp.php
new file mode 100644
index 0000000..cfa8d84
--- /dev/null
+++ b/test/OfflineHttp.php
@@ -0,0 +1,304 @@
+> */
+ private $records = array();
+
+ /**
+ * @param string $method
+ * @param string $url
+ * @param string[] $headers
+ * @param string|null $body
+ * @param int $timeout
+ * @return array{status: int, headers: string[], body: string}
+ */
+ public function handle($method, $url, array $headers, $body, $timeout)
+ {
+ $parsed = parse_url($url);
+ $host = isset($parsed['host']) ? $parsed['host'] : '';
+ $path = isset($parsed['path']) ? $parsed['path'] : '';
+ $query = array();
+ if (!empty($parsed['query'])) {
+ parse_str($parsed['query'], $query);
+ }
+ $contentType = '';
+ foreach ($headers as $header) {
+ if (stripos($header, 'Content-Type:') === 0) {
+ $contentType = trim(substr($header, strlen('Content-Type:')));
+ }
+ }
+ $fields = array();
+ foreach ($query as $key => $value) {
+ $fields[$key] = is_array($value) ? json_encode($value) : (string)$value;
+ }
+ if ($body !== null && $body !== '') {
+ $fields = array_merge($fields, $this->fieldsFromBody($body, $contentType));
+ }
+ $apiKey = isset($fields['api_key']) ? (string)$fields['api_key'] : '';
+ $record = array(
+ 'method' => strtoupper($method),
+ 'url' => $url,
+ 'host' => $host,
+ 'path' => $path,
+ 'headers' => $headers,
+ 'body' => $body === null ? '' : $body,
+ 'content_type' => $contentType,
+ 'fields' => $fields,
+ 'api_key' => $apiKey,
+ 'timeout' => $timeout,
+ );
+ $this->records[] = $record;
+ usleep(random_int(0, 3000));
+ return $this->answer($record);
+ }
+
+ public function clear()
+ {
+ $this->records = array();
+ }
+
+ /**
+ * @return array>
+ */
+ public function take()
+ {
+ $records = $this->records;
+ $this->records = array();
+ return $records;
+ }
+
+ public function count()
+ {
+ return count($this->records);
+ }
+
+ /**
+ * @param array $request
+ * @return array{status: int, headers: string[], body: string}
+ */
+ private function answer(array $request)
+ {
+ $key = $request['api_key'];
+ $path = $request['path'];
+ if (strpos($key, 'html-403-') === 0 || strpos($key, 'html-200-') === 0) {
+ $status = strpos($key, 'html-403-') === 0 ? 403 : 200;
+ return $this->respond($status, 'text/html; charset=UTF-8', self::accessDeniedPage());
+ }
+ if (strpos($key, 'empty-') === 0) {
+ return $this->respond(200, 'application/json', '');
+ }
+ if (strpos($key, 'malformed-') === 0) {
+ return $this->respond(200, 'application/json', '<<>>' . str_repeat('x', 5000));
+ }
+ if (strpos($key, 'cf-json-') === 0) {
+ $body = '{"type":"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1020/","title":"Error 1020: Access denied","status":403,"detail":"The request was blocked by a Cloudflare firewall rule configured by the site owner.","error_code":1020,"cloudflare_error":true,"what_you_should_do":"Do not retry. This block is intentional."}';
+ return $this->respond(403, 'application/json', $body);
+ }
+ if (strpos($key, 'double-error-') === 0) {
+ return $this->respond(400, 'text/plain', 'Error: Error: api_key is invalid');
+ }
+ if (strpos($key, 'account-invalid-') === 0 || strpos($key, 'account-nocredits-') === 0) {
+ $message = strpos($key, 'account-nocredits-') === 0
+ ? 'Your account ran out of credits'
+ : 'Invalid API Key or your account ran out of credits';
+ if (strpos($path, 'validatebatch') !== false) {
+ $body = json_encode(array(
+ 'email_batch' => array(),
+ 'errors' => array(array(
+ 'email_address' => 'all',
+ 'error' => $message,
+ )),
+ ));
+ } else {
+ $body = json_encode(array('error' => $message));
+ }
+ return $this->respond(200, 'application/json', $body);
+ }
+ if (strpos($path, 'validatebatch') !== false && strpos($key, 'batch-partial-') === 0) {
+ $body = json_encode(array(
+ 'email_batch' => array(array(
+ 'address' => 'good@example.com',
+ 'status' => 'valid',
+ )),
+ 'errors' => array(array(
+ 'email_address' => 'bad@',
+ 'error' => 'Invalid email address',
+ )),
+ ));
+ return $this->respond(200, 'application/json', $body);
+ }
+
+ $quoted = json_encode($key);
+ if (strpos($path, 'getfile') !== false) {
+ return $this->respond(200, 'text/csv', "email\n" . $key . "@example.com\n");
+ }
+ if (strpos($path, 'listS3files') !== false) {
+ return $this->respond(200, 'application/json', '[{"file_name":' . $quoted . ',"size":1}]');
+ }
+ if (strpos($path, 'validatebatch') !== false) {
+ return $this->respond(200, 'application/json', '{"email_batch":[{"address":' . $quoted . ',"status":"valid"}],"errors":[]}');
+ }
+ if (substr($path, -9) === '/validate') {
+ return $this->respond(200, 'application/json', '{"address":' . $quoted . ',"status":"valid"}');
+ }
+ if (substr($path, -8) === '/scoring') {
+ return $this->respond(200, 'application/json', '{"email":' . $quoted . ',"score":10}');
+ }
+ if (strpos($path, 'getcredits') !== false) {
+ return $this->respond(200, 'application/json', '{"Credits":"7"}');
+ }
+ if (strpos($path, 'getapiusage') !== false) {
+ return $this->respond(200, 'application/json', '{"total":1}');
+ }
+ if (substr($path, -9) === '/activity') {
+ return $this->respond(200, 'application/json', '{"found":true,"active_in_days":"3"}');
+ }
+ if (strpos($path, 'filters') !== false || (strpos($path, 'listevaluator') !== false && strpos($path, 'sendfile') === false && $request['method'] === 'GET')) {
+ return $this->respond(200, 'application/json', '{"echo_api_key":' . $quoted . '}');
+ }
+ if (strpos($path, 'guessformat') !== false) {
+ return $this->respond(200, 'application/json', '{"email":' . $quoted . ',"domain":' . $quoted . ',"format":"first.last","confidence":"high","other_domain_formats":[]}');
+ }
+ return $this->respond(
+ 200,
+ 'application/json',
+ '{"success":true,"message":"ok","file_id":' . $quoted . ',"file_name":"f.csv","email":' . $quoted . ',"domain":' . $quoted . ',"other_domain_formats":[]}'
+ );
+ }
+
+ /**
+ * @return array{status: int, headers: string[], body: string}
+ */
+ private function respond($status, $contentType, $body)
+ {
+ $reason = $status === 200 ? 'OK' : ($status === 403 ? 'Forbidden' : 'Error');
+ return array(
+ 'status' => $status,
+ 'headers' => array(
+ 'HTTP/1.1 ' . $status . ' ' . $reason,
+ 'Content-Type: ' . $contentType,
+ ),
+ 'body' => $body,
+ );
+ }
+
+ /**
+ * @return array
+ */
+ private function fieldsFromBody($body, $contentType)
+ {
+ if (stripos($contentType, 'application/json') !== false || (isset($body[0]) && ($body[0] === '{' || $body[0] === '['))) {
+ $decoded = json_decode($body, true);
+ $fields = array('__json' => $body);
+ if (!is_array($decoded) || $this->isList($decoded)) {
+ return $fields;
+ }
+ foreach ($decoded as $key => $value) {
+ if (is_bool($value)) {
+ $fields[$key] = $value ? 'true' : 'false';
+ } elseif (is_array($value)) {
+ $fields[$key] = json_encode($value);
+ } elseif ($value === null) {
+ $fields[$key] = '';
+ } else {
+ $fields[$key] = (string)$value;
+ }
+ }
+ return $fields;
+ }
+ if (stripos($contentType, 'multipart/form-data') !== false) {
+ return $this->parseMultipart($body, $contentType);
+ }
+ $parsed = array();
+ parse_str($body, $parsed);
+ $fields = array();
+ foreach ($parsed as $key => $value) {
+ $fields[$key] = is_array($value) ? json_encode($value) : (string)$value;
+ }
+ return $fields;
+ }
+
+ /**
+ * @param array $value
+ */
+ private function isList(array $value)
+ {
+ if ($value === array()) {
+ return true;
+ }
+ return array_keys($value) === range(0, count($value) - 1);
+ }
+
+ /**
+ * @return array
+ */
+ private function parseMultipart($body, $contentType)
+ {
+ $fields = array();
+ if (!preg_match('/boundary=([^;]+)/', $contentType, $match)) {
+ $fields['__raw'] = $body;
+ return $fields;
+ }
+ $boundary = trim($match[1], " \t\"");
+ $parts = explode('--' . $boundary, $body);
+ foreach ($parts as $part) {
+ if (strpos($part, 'Content-Disposition') === false) {
+ continue;
+ }
+ if (!preg_match('/name="([^"]+)"/', $part, $name)) {
+ continue;
+ }
+ $split = preg_split("/\r\n\r\n/", $part, 2);
+ $value = isset($split[1]) ? preg_replace("/\r\n$/", '', $split[1]) : '';
+ if (strpos($part, 'filename=') !== false) {
+ $fields[$name[1]] = '';
+ if (preg_match('/filename="([^"]*)"/', $part, $fileName)) {
+ $fields['__filename'] = $fileName[1];
+ }
+ continue;
+ }
+ $fields[$name[1]] = $value;
+ }
+ return $fields;
+ }
+
+ public static function accessDeniedPage()
+ {
+ return 'Access denied | zerobounce.net | Zero Bounce'
+ . str_repeat('blocked
', 20000)
+ . '';
+ }
+}
+
+class OfflineClient extends ZeroBounce
+{
+ /** @var FakeApi */
+ public $fake;
+
+ public function __construct(FakeApi $fake)
+ {
+ parent::__construct();
+ $this->fake = $fake;
+ }
+
+ protected function executeHttp($method, $url, array $headers = array(), $body = null, $timeout = 120)
+ {
+ return $this->fake->handle($method, $url, $headers, $body, $timeout);
+ }
+}
+
+class DirectClient extends ZeroBounce
+{
+ public function __construct()
+ {
+ parent::__construct();
+ }
+}
diff --git a/test/ZeroBounceHttpHeadersPhp85Test.php b/test/ZeroBounceHttpHeadersPhp85Test.php
index 7fc119f..82f760a 100644
--- a/test/ZeroBounceHttpHeadersPhp85Test.php
+++ b/test/ZeroBounceHttpHeadersPhp85Test.php
@@ -41,6 +41,11 @@ public function testGetBulkFileHttpSourceHasNoIssetOnHttpResponseHeaderOnPhp85()
}
$src = file_get_contents(self::zeroBounceSourcePath());
$method = self::extractMethodSource(
+ $src,
+ 'private function lastHttpHeaders()',
+ 'protected function request($url, $response)'
+ );
+ $bulk = self::extractMethodSource(
$src,
'protected function getBulkFileHttp($url)',
'protected static function parseHttpStatusFromHeaders'
@@ -48,35 +53,39 @@ public function testGetBulkFileHttpSourceHasNoIssetOnHttpResponseHeaderOnPhp85()
$this->assertStringContainsString("function_exists('http_get_last_response_headers')", $method);
$this->assertStringContainsString('http_get_last_response_headers()', $method);
- $this->assertStringNotContainsString(
- 'isset($http_response_header)',
- $method,
- 'Remove isset($http_response_header) / elseif legacy branch; it is deprecated on PHP 8.5 (see RFC deprecations).'
- );
+ $this->assertStringNotContainsString('isset($http_response_header)', $method);
+ $this->assertStringNotContainsString('isset($http_response_header)', $bulk);
+ $this->assertStringContainsString('executeHttp', $bulk);
}
public function testRequestUsesHttpGetLastResponseHeadersBeforeReadingStatus(): void
{
$src = file_get_contents(self::zeroBounceSourcePath());
- $method = self::extractMethodSource(
+ $execute = self::extractMethodSource(
$src,
- 'protected function request($url, $response)',
- 'private function checkValidApiKey'
+ 'protected function executeHttp($method, $url, array $headers = array(), $body = null, $timeout = 120)',
+ 'private function lastHttpHeaders()'
+ );
+ $headers = self::extractMethodSource(
+ $src,
+ 'private function lastHttpHeaders()',
+ 'protected function request($url, $response)'
);
- $this->assertStringContainsString("function_exists('http_get_last_response_headers')", $method);
- $this->assertStringContainsString('http_get_last_response_headers()', $method);
- $this->assertStringContainsString('$responseHeaders = http_get_last_response_headers()', $method);
- $this->assertStringNotContainsString('getHttpCode($http_response_header)', $method);
+ $this->assertStringContainsString("function_exists('http_get_last_response_headers')", $headers);
+ $this->assertStringContainsString('http_get_last_response_headers()', $headers);
+ $this->assertStringContainsString('$fromFn = http_get_last_response_headers()', $headers);
+ $this->assertStringNotContainsString('getHttpCode($http_response_header)', $execute);
+ $this->assertStringNotContainsString('isset($http_response_header)', $execute);
- $posFn = strpos($method, "function_exists('http_get_last_response_headers')");
- $posGetHttp = strpos($method, 'getHttpCode($responseHeaders)');
- $this->assertNotFalse($posFn);
+ $posHeaders = strpos($execute, 'lastHttpHeaders()');
+ $posGetHttp = strpos($execute, 'getHttpCode($responseHeaders)');
+ $this->assertNotFalse($posHeaders);
$this->assertNotFalse($posGetHttp);
$this->assertLessThan(
$posGetHttp,
- $posFn,
- 'request() must resolve headers via http_get_last_response_headers() before getHttpCode() (PHP 8.5).'
+ $posHeaders,
+ 'executeHttp() must resolve headers via lastHttpHeaders() before getHttpCode() (PHP 8.5).'
);
}
diff --git a/test/ZeroBounceTest.php b/test/ZeroBounceTest.php
index 423e034..b1c176f 100644
--- a/test/ZeroBounceTest.php
+++ b/test/ZeroBounceTest.php
@@ -248,6 +248,21 @@ public function testGetActivity()
$this->assertEquals($response->activeInDays, 180);
}
+ public function testScoreEmail()
+ {
+ ZeroBounce::Instance()->responseText = "{
+ \"email\": \"mark@gmail.com\",
+ \"score\": 10
+ }";
+ $response = ZeroBounce::Instance()->scoreEmail("mark@gmail.com");
+ $this->assertEquals($response->email, "mark@gmail.com");
+ $this->assertEquals($response->score, 10);
+
+ $responsePost = ZeroBounce::Instance()->scoreEmailPost("mark@gmail.com");
+ $this->assertEquals($responsePost->email, "mark@gmail.com");
+ $this->assertEquals($responsePost->score, 10);
+ }
+
public function testSendFile()
{
ZeroBounce::Instance()->responseText = "{
@@ -702,4 +717,45 @@ public function testFindEmailFormat()
$this->assertEquals($response->confidence, "high");
$this->assertEquals(count($response->otherDomainFormats), 26);
}
+
+ public function testSendEmailFinderFileUsesBulkEmailFinderEndpoint()
+ {
+ ZeroBounce::Instance()->responseText = '{
+ "success": true,
+ "file_id": "abc-def",
+ "message": "File Accepted"
+ }';
+ $path = sys_get_temp_dir() . '/zb_finder_p3.csv';
+ file_put_contents($path, "domain,name\nacme.com,Jane");
+ try {
+ ZeroBounce::Instance()->sendEmailFinderFile($path, 1);
+ $this->assertStringContainsString('bulkapi.zerobounce.net/email-finder/sendfile', ZeroBounce::Instance()->lastCurlUrl);
+ } finally {
+ if (file_exists($path)) {
+ unlink($path);
+ }
+ }
+ }
+
+ public function testValidateS3FilePostsToBulkRoot()
+ {
+ ZeroBounce::Instance()->responseText = '{
+ "success": true,
+ "file_id": "s3-job",
+ "message": "File Accepted"
+ }';
+ ZeroBounce::Instance()->validateS3File(array('file_name' => 'bucket/key.csv'));
+ $this->assertStringContainsString('bulkapi.zerobounce.net/validateS3file', ZeroBounce::Instance()->lastFormPostUrl);
+ }
+
+ public function testListS3FilesCallsBulkListS3files()
+ {
+ ZeroBounce::Instance()->responseText = '[{"file_name":"bucket/key.csv","size":1024}]';
+ $result = ZeroBounce::Instance()->listS3Files('my-bucket', 'key');
+ $this->assertStringContainsString('bulkapi.zerobounce.net/listS3files', ZeroBounce::Instance()->lastRequestUrl);
+ $this->assertStringContainsString('bucket=my-bucket', ZeroBounce::Instance()->lastRequestUrl);
+ $this->assertStringContainsString('search=key', ZeroBounce::Instance()->lastRequestUrl);
+ $this->assertIsArray($result);
+ $this->assertEquals('bucket/key.csv', $result[0]['file_name']);
+ }
}