From 134d1815932e2cc0041759a5bb344bb490a147f7 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:51:59 +0400 Subject: [PATCH 1/7] feat: v2 validate parity (verify_plus, activity_data, batch timeout) Add ZBValidateOptions for validate/validateBatch and activity fields on responses. Co-authored-by: Cursor --- src/ZBValidateOptions.php | 18 ++++++++++++++++++ src/ZBValidateResponse.php | 6 ++++++ src/ZeroBounce.php | 29 ++++++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 src/ZBValidateOptions.php diff --git a/src/ZBValidateOptions.php b/src/ZBValidateOptions.php new file mode 100644 index 0000000..1acee0f --- /dev/null +++ b/src/ZBValidateOptions.php @@ -0,0 +1,18 @@ +checkValidApiKey(); if (!$email) throw new ZBMissingParameterException("email is required"); $response = new ZBValidateResponse(); $ipPart = $ipAddress ? urlencode($ipAddress) : ""; - $this->request(self::$ApiBaseUrl . "/validate?api_key=" . urlencode($this->apiKey) . "&email=" . urlencode($email) . "&ip_address=" . $ipPart, $response); + $url = self::$ApiBaseUrl . "/validate?api_key=" . urlencode($this->apiKey) . "&email=" . urlencode($email) . "&ip_address=" . $ipPart; + 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,7 +84,7 @@ 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"); @@ -98,6 +110,17 @@ 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( From c301cfa21ad7f7714c4c90ceb55fb3bd740cf9be Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:49:11 +0400 Subject: [PATCH 2/7] feat: add scoreEmail GET/POST for /v2/scoring Co-authored-by: Cursor --- src/ZBScoreEmailResponse.php | 23 ++++++++++++ src/ZeroBounce.php | 68 ++++++++++++++++++++++++++++++++++++ test/ZeroBounceTest.php | 15 ++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/ZBScoreEmailResponse.php 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/ZeroBounce.php b/src/ZeroBounce.php index 9c1f292..aff3916 100644 --- a/src/ZeroBounce.php +++ b/src/ZeroBounce.php @@ -184,6 +184,42 @@ public function getActivity($email) 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(self::$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(self::$ApiBaseUrl . "/scoring", array('email' => $email), $response); + return $response; + } + /** * The sendfile API allows user to send a file for bulk email validation * @param string $filepath @@ -413,6 +449,38 @@ protected function json($url, $data, $response) return $code; } + /** + * @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; + + $curl = curl_init($url); + curl_setopt_array($curl, array( + CURLOPT_URL => $url, + CURLOPT_HEADER => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($data), + )); + + $json = curl_exec($curl); + curl_close($curl); + + if (!$json) { + throw new ZBException("No response"); + } + + $response->Deserialize($json); + return 200; + } + /** * this function is separated like this for easy mocking in the tests * @param string $url diff --git a/test/ZeroBounceTest.php b/test/ZeroBounceTest.php index 423e034..bcc2917 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 = "{ From d35863d02ed0ef03d1c0254b9a80bafb3a40667e Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:45:15 +0400 Subject: [PATCH 3/7] feat: Priority 3 API parity (bulk finder, filters, S3) Co-authored-by: Cursor --- src/ZBRawJsonResponse.php | 32 +++ src/ZeroBounce.php | 1 + src/ZeroBouncePriority3.php | 390 ++++++++++++++++++++++++++++++++++++ test/MockZeroBounce.php | 37 ++++ test/ZeroBounceTest.php | 30 +++ 5 files changed, 490 insertions(+) create mode 100644 src/ZBRawJsonResponse.php create mode 100644 src/ZeroBouncePriority3.php 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/ZeroBounce.php b/src/ZeroBounce.php index aff3916..fe939f4 100644 --- a/src/ZeroBounce.php +++ b/src/ZeroBounce.php @@ -9,6 +9,7 @@ */ class ZeroBounce { + use ZeroBouncePriority3; /** * Call this method to get singleton diff --git a/src/ZeroBouncePriority3.php b/src/ZeroBouncePriority3.php new file mode 100644 index 0000000..21bf36a --- /dev/null +++ b/src/ZeroBouncePriority3.php @@ -0,0 +1,390 @@ +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 + ) { + if (!$domainColumn) { + throw new ZBMissingParameterException('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) + { + if (!$domainColumn) { + throw new ZBMissingParameterException('domainColumn is required'); + } + $fields = array('domain_column' => $domainColumn); + if ($hasHeaderRow) { + $fields['has_header_row'] = 'true'; + } + return $this->sendBulkRootMultipartFile(self::DomainSearchBulkBaseUrl . '/sendfile', $filepath, $fields); + } + + public function sendDomainSearchFileStream($streamContent, $fileName, $domainColumn, $hasHeaderRow = null) + { + if (!$domainColumn) { + throw new ZBMissingParameterException('domainColumn is required'); + } + $fields = array('domain_column' => $domainColumn); + if ($hasHeaderRow) { + $fields['has_header_row'] = 'true'; + } + 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) + { + if (!$emailAddressColumn) { + throw new ZBMissingParameterException('emailAddressColumn is required'); + } + $fields = array('email_address_column' => $emailAddressColumn); + return $this->sendBulkRootMultipartFile(self::BulkApiBaseUrl . '/listevaluator/', $filepath, $fields); + } + + public function sendListEvaluatorFileStream($streamContent, $fileName, $emailAddressColumn) + { + if (!$emailAddressColumn) { + throw new ZBMissingParameterException('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(); + if (!$fileId) { + throw new ZBMissingParameterException('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 (!$rule || !$target || !$value) { + throw new ZBMissingParameterException('rule, target, and value are required'); + } + return $this->filterFormPost(self::$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(self::$ApiBaseUrl . '/filters/list', array()); + } + $url = self::$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 (!$rule || !$target || !$value) { + throw new ZBMissingParameterException('rule, target, and value are required'); + } + return $this->filterFormPost(self::$ApiBaseUrl . '/filters/delete', array( + 'rule' => $rule, + 'target' => $target, + 'value' => $value, + )); + } + + public function validateS3File(array $options) + { + if (empty($options['file_name'])) { + throw new ZBMissingParameterException('file_name is required'); + } + return $this->postBulkRootForm('/validateS3file', $this->buildValidateS3Fields($options)); + } + + public function scoreS3File(array $options) + { + if (empty($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) + { + if (!$fileId) { + throw new ZBMissingParameterException('fileId is required'); + } + if (!$exportBucket) { + throw new ZBMissingParameterException('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) { + $fields['has_header_row'] = 'true'; + } + if ($firstNameColumn) { + $fields['first_name_column'] = $firstNameColumn; + } + if ($lastNameColumn) { + $fields['last_name_column'] = $lastNameColumn; + } + if ($middleNameColumn) { + $fields['middle_name_column'] = $middleNameColumn; + } + if ($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 (!empty($options['first_name_column'])) { + $fields['first_name_column'] = $options['first_name_column']; + } + if (!empty($options['last_name_column'])) { + $fields['last_name_column'] = $options['last_name_column']; + } + if (!empty($options['gender_column'])) { + $fields['gender_column'] = $options['gender_column']; + } + if (!empty($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) + { + $rsp = new ZBSendFileResponse(); + $this->formPost(self::BulkApiRootUrl . $path, $fields, $rsp); + return $rsp; + } + + private function bulkRootFileStatus($baseUrl, $fileId) + { + $this->checkValidApiKey(); + if (!$fileId) { + throw new ZBMissingParameterException('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(); + if (!$fileId) { + throw new ZBMissingParameterException('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(); + if (!$fileId) { + throw new ZBMissingParameterException('fileId is required'); + } + if (!$downloadPath) { + throw new ZBMissingParameterException('downloadPath is required'); + } + $url = $baseUrl . '/getfile?api_key=' . urlencode($this->apiKey) . '&file_id=' . urlencode($fileId); + $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 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/ZeroBounceTest.php b/test/ZeroBounceTest.php index bcc2917..a2ba9f5 100644 --- a/test/ZeroBounceTest.php +++ b/test/ZeroBounceTest.php @@ -717,4 +717,34 @@ 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); + } } From ad17676ee730fa57eafa09f62eaa9115b1923712 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:17:25 +0400 Subject: [PATCH 4/7] feat: POST /validate transport parity and listS3files Add form-encoded POST option on single validate (alongside GET) and GET listS3files on bulkapi root, with unit tests and API parity doc updates. --- src/ZBValidateOptions.php | 3 +++ src/ZeroBounce.php | 22 +++++++++++++++++++++- src/ZeroBouncePriority3.php | 18 ++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/ZBValidateOptions.php b/src/ZBValidateOptions.php index 1acee0f..4263aa7 100644 --- a/src/ZBValidateOptions.php +++ b/src/ZBValidateOptions.php @@ -15,4 +15,7 @@ class ZBValidateOptions /** @var bool|null */ public $verifyPlus; + + /** @var bool When true, uses form-encoded POST for /validate. */ + public $usePost = false; } diff --git a/src/ZeroBounce.php b/src/ZeroBounce.php index fe939f4..7e4fca8 100644 --- a/src/ZeroBounce.php +++ b/src/ZeroBounce.php @@ -62,7 +62,27 @@ public function validate($email, $ipAddress = NULL, $options = NULL) if (!$email) throw new ZBMissingParameterException("email is required"); $response = new ZBValidateResponse(); $ipPart = $ipAddress ? urlencode($ipAddress) : ""; - $url = self::$ApiBaseUrl . "/validate?api_key=" . urlencode($this->apiKey) . "&email=" . urlencode($email) . "&ip_address=" . $ipPart; + $fields = array( + 'email' => $email, + 'ip_address' => $ipAddress ? $ipAddress : '', + ); + 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(self::$ApiBaseUrl . "/validate", $fields, $response); + return $response; + } + } + $url = self::$ApiBaseUrl . "/validate?api_key=" . urlencode($this->apiKey) + . "&email=" . urlencode($email) . "&ip_address=" . $ipPart; if ($options instanceof ZBValidateOptions) { if ($options->timeout !== null) { $url .= "&timeout=" . urlencode((string)$options->timeout); diff --git a/src/ZeroBouncePriority3.php b/src/ZeroBouncePriority3.php index 21bf36a..70eaef4 100644 --- a/src/ZeroBouncePriority3.php +++ b/src/ZeroBouncePriority3.php @@ -190,6 +190,24 @@ public function deleteFilter($rule, $target, $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 (empty($options['file_name'])) { From 0a42c74dc43dbcc96684ea9d96f4e398e65b2cba Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:50:33 +0400 Subject: [PATCH 5/7] test: add PHPUnit coverage for listS3Files Assert the bulk listS3files URL and parsed array response using the existing mock request harness. Co-authored-by: Cursor --- test/ZeroBounceTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/ZeroBounceTest.php b/test/ZeroBounceTest.php index a2ba9f5..b1c176f 100644 --- a/test/ZeroBounceTest.php +++ b/test/ZeroBounceTest.php @@ -747,4 +747,15 @@ public function testValidateS3FilePostsToBulkRoot() 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']); + } } From 7d8c4ffa1726a3a2f365a56d7f075566796c8aa2 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:36:37 +0400 Subject: [PATCH 6/7] fix: reject bad HTTP bodies and cover the API offline HTML, empty, and account-level batch errors become short exceptions, column 0 and false flags are sent, and redirects are disabled. Offline tests, the changelog, and agent notes record that behavior. Co-authored-by: Cursor --- .github/workflows/publish.yml | 3 + .github/workflows/sdk_ci.yml | 3 + AGENTS.md | 57 ++ CHANGELOG.md | 122 +++ composer.json | 4 +- src/ZBHttpError.php | 178 +++++ src/ZeroBounce.php | 529 ++++++++----- src/ZeroBouncePriority3.php | 119 +-- test/OfflineApiTest.php | 986 ++++++++++++++++++++++++ test/OfflineHttp.php | 304 ++++++++ test/ZeroBounceHttpHeadersPhp85Test.php | 43 +- 11 files changed, 2056 insertions(+), 292 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 src/ZBHttpError.php create mode 100644 test/OfflineApiTest.php create mode 100644 test/OfflineHttp.php 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..58a58e0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project + +`zero-bounce/sdk` is the ZeroBounce PHP SDK. Callers use `ZeroBounce::create()`, call `initialize` with an API key, and read the returned response object. Failures throw `ZBException` (or `ZBMissingApiKeyException` / `ZBMissingParameterException` before a request). HTTP goes through `executeHttp` in `src/ZeroBounce.php`. `src/ZBHttpError.php` turns HTML, empty bodies, and bad JSON into a short message. + +## Layout + +- `src/ZeroBounce.php`: public client. `executeHttp` is the lowest HTTP call. +- `src/ZeroBouncePriority3.php`: bulk email finder, domain search, list evaluator, filters, and S3. It is a trait on `ZeroBounce`. +- `src/ZB*.php`: response models and options. +- `test/OfflineApiTest.php` and `test/OfflineHttp.php`: offline suite. `OfflineClient` overrides `executeHttp`. No network and no real API key. +- `test/ZeroBounceTest.php`: existing PHPUnit cases with a scripted response. +- `composer.json`: the package version field, `"version": "x.y.z"`. `composer.lock` lists dependencies, not this package's version. There is no runtime version constant. + +## Commands + +```bash +composer install +composer lint # php -l on src and test; this is the syntax check +composer test # phpunit +``` + +There is no separate typechecker. CI (`.github/workflows/sdk_ci.yml`) runs `php -l` and then `vendor/bin/phpunit`. PHPUnit collects every file in `test/`, including `OfflineApiTest.php`. + +## Conventions + +- **One client per API key.** `ZeroBounce::Instance()` is a process-wide singleton. Do not reuse it across users or requests. Call `ZeroBounce::create()` for each key. The offline concurrency test does this. +- **Column indexes are 1-based and are not converted.** Optional name, gender, and IP columns use `0` to mean "leave this field out". Required `email_address_column` and `domain_column` are sent as given, including `0`. +- **Optional parameters are omitted when unset.** `timeout`, `activityData`, `verifyPlus`, `allowPhase2`, and `downloadType` are not sent until the caller sets them. Explicit `false` is sent as the string `false` on query and multipart bodies, and as a JSON boolean on `validateBatch`. +- **Booleans on multipart must be the strings `true` and `false`.** A PHP bool in a multipart part becomes `1` or `0`, and the API will not treat that as a boolean. +- **`allow_phase_2` is validation `sendFile` and `validateS3File` only.** Scoring `sendFile` ignores it. `activity_data` on get-file is validation `getFile` only. +- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. +- **Failures throw `ZBException`.** The message must be short: no raw HTML, no megabyte body, no parser exception text, and no `Error: Error:` prefix. An account-level `validateBatch` error (`email_address` `all`) throws. A per-address error stays on the result. +- **`executeHttp` does not follow redirects** (`CURLOPT_FOLLOWLOCATION` is false, and `file_get_contents` uses `follow_location` 0 and `max_redirects` 0). A 3xx response is an error. Do not turn redirects back on. +- **Tests override `executeHttp`.** It is `protected`. Subclass `ZeroBounce` in the test. Do not call the live API from `composer test`. +- Match the surrounding style. Do not reformat unrelated code. Do not commit `vendor/`, `.env`, or `.phpunit.cache`. + +## 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": "x.y.z"`). That is the only package version field. The publish workflow fails if it differs from the tag. +3. `composer lint` and `composer test`. +4. Tag `vx.y.z` on that commit and run the Publish workflow with that tag. It publishes `zero-bounce/sdk` to Packagist. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..86e45a6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,122 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `validate` takes `ZBValidateOptions`: `timeout`, `activityData`, `verifyPlus`, and `usePost` (form-encoded `POST /validate`). +- `validateBatch` takes the same options. `timeout` is 10–120 seconds. +- `scoreEmail` (`GET /v2/scoring`) and a form-encoded `POST /v2/scoring` when `usePost` is set. +- Bulk email finder (`sendEmailFinderFile`, `emailFinderFileStatus`, `emailFinderGetFile`, `emailFinderDeleteFile`). +- Bulk domain search (`sendDomainSearchFile`, `domainSearchFileStatus`, `domainSearchGetFile`, `domainSearchDeleteFile`). +- List evaluator (`sendListEvaluatorFile`, `getListEvaluatorResult`, `sendEvaluatedFile`). +- Filters (`addFilter`, `listFilters`, `deleteFilter`). +- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3files`). +- `ZeroBounce::create()` returns a new client. `Instance()` stays a process-wide singleton. + +### Changed + +- API requests time out after 120 seconds and do not follow redirects. +- A custom base URL must be `https://`, except a test may point `getFile` at `http://127.0.0.1` or `http://localhost`. + +### Fixed + +- A 403 HTML "Access denied" page (about 1.4 MB from the live API on `getFile`) is thrown as a short `ZBException`. An HTML page with status 200 is an error as well, including `getFile`, `emailFinderGetFile`, and `domainSearchGetFile`, instead of a saved CSV or a JSON parse. +- Empty and non-JSON bodies are a short `ZBException`. The message is no longer raw parser text, and a repeated `Error:` / `[Error]:` prefix is removed. +- `validateBatch` throws when the body is an account-level failure (HTTP 200, error for address `all`, such as an invalid API key or no credits). An error for one address stays on the returned result. +- `sendFile` and `scoringSendFile` send `has_header_row` and `remove_duplicate` as `true` or `false`. Bulk email finder and domain search send `has_header_row` when it is set to false. +- Required column indexes are sent as given, including `0`. Optional name, gender, and IP columns of `0` are left out. +- `initialize('')` throws `ZBMissingApiKeyException` before a request is sent. + +### Security + +- Query values are percent-encoded, and redirects are disabled, so a value cannot rewrite the request URL or send the client to another host. +- Error 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 + +- Validate responses include `catchall_domain`. + +## [2.1.6] - 2026-04-16 + +### Fixed + +- HTTP helpers no longer read the deprecated `$http_response_header` on PHP 8.5. + +## [2.1.1] - 2026-04-15 + +### Fixed + +- `getFile` reads response headers on PHP 8.5 when `http_get_last_response_headers()` is not an array. + +## [2.1.0] - 2026-04-05 + +### Added + +- `sendFile` takes `allowPhase2`, sent as `allow_phase_2` on validation bulk only. +- `getFile` and `scoringGetFile` take `ZBGetFileOptions` with `downloadType` (`phase_1`, `phase_2`, `combined`). Validation `getFile` also takes `activityData`. +- `ZBFileStatusResponse` includes the phase 2 status. + +## [2.0.11] - 2026-03-03 + +### Changed + +- License the project under MIT. + +## [2.0.0] - 2026-02-26 + +### Changed + +- Response models for credits, API usage, batch validation, file status, and email finder match the 2.0 payloads. + +## [1.2.0] - 2025-10-27 + +### Added + +- `findEmailByDomain`, `findEmailByCompanyName`, and the format helpers. +- `initialize($apiKey, $baseUrl)` selects the default, US, or EU API host. +- `ZBValidateSubStatus::AcceptAll`. + +## [1.1.3] - 2023-09-05 + +### Added + +- `guessFormat`. + +## [1.1.0] - 2023-05-17 + +### Added + +- `validateBatch`. + +## [1.0.0] - 2023-04-06 + +### Added + +- First stable release: `getCredits`, `getApiUsage`, `validate`, activity, and 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.6...v2.1.9 +[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.3...v1.2.0 +[1.1.3]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.0...v1.1.3 +[1.1.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/releases/tag/v1.0.0 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/ZeroBounce.php b/src/ZeroBounce.php index 7e4fca8..6922388 100644 --- a/src/ZeroBounce.php +++ b/src/ZeroBounce.php @@ -25,16 +25,27 @@ public static function Instance() return $inst; } + /** + * A new client. Use one instance per API key. Instance() is process-wide. + * + * @return static + */ + public static function create() + { + return new static(); + } + /** * Private constructor so nobody else can instantiate it * */ protected function __construct() { - + $this->apiBaseUrl = ZBBaseUrl::API_DEFAULT_URL; } - protected static $ApiBaseUrl; + /** @var string */ + protected $apiBaseUrl; const BulkApiBaseUrl = "https://bulkapi.zerobounce.net/v2"; private $apiKey = null; @@ -45,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); } /** @@ -60,11 +71,14 @@ 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) : ""; + $ipValue = ($ipAddress === null) ? '' : (string)$ipAddress; $fields = array( 'email' => $email, - 'ip_address' => $ipAddress ? $ipAddress : '', + 'ip_address' => $ipValue, ); if ($options instanceof ZBValidateOptions) { if ($options->timeout !== null) { @@ -77,12 +91,12 @@ public function validate($email, $ipAddress = NULL, $options = NULL) $fields['verify_plus'] = $options->verifyPlus ? "true" : "false"; } if ($options->usePost) { - $this->formPost(self::$ApiBaseUrl . "/validate", $fields, $response); + $this->formPost($this->apiBaseUrl . "/validate", $fields, $response); return $response; } } - $url = self::$ApiBaseUrl . "/validate?api_key=" . urlencode($this->apiKey) - . "&email=" . urlencode($email) . "&ip_address=" . $ipPart; + $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); @@ -109,6 +123,9 @@ 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') @@ -144,8 +161,9 @@ function ($email) { } $response = new ZBBatchValidateResponse(); - $code = $this->json( - self::$ApiBaseUrl . "/validatebatch", $params, $response); + $this->json( + $this->apiBaseUrl . "/validatebatch", $params, $response); + $this->throwIfBatchAccountError($response); return $response; } @@ -158,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; } @@ -178,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); @@ -199,7 +217,7 @@ 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; @@ -218,7 +236,7 @@ public function scoreEmail($email) if (!$email) throw new ZBMissingParameterException("email is required"); $response = new ZBScoreEmailResponse(); - $this->request(self::$ApiBaseUrl . "/scoring?api_key=" . urlencode($this->apiKey) + $this->request($this->apiBaseUrl . "/scoring?api_key=" . urlencode($this->apiKey) . "&email=" . urlencode($email), $response); return $response; @@ -237,7 +255,7 @@ public function scoreEmailPost($email) if (!$email) throw new ZBMissingParameterException("email is required"); $response = new ZBScoreEmailResponse(); - $this->formPost(self::$ApiBaseUrl . "/scoring", array('email' => $email), $response); + $this->formPost($this->apiBaseUrl . "/scoring", array('email' => $email), $response); return $response; } @@ -351,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); @@ -404,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); @@ -444,30 +442,17 @@ 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); - - if (!$json) { - throw new ZBException("No response"); - } - + $json = $this->readJsonBody($result); $response->Deserialize($json); - return $code; + return (int)$result['status']; } /** @@ -480,26 +465,18 @@ protected function json($url, $data, $response) 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 + ); - $curl = curl_init($url); - curl_setopt_array($curl, array( - CURLOPT_URL => $url, - CURLOPT_HEADER => false, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 120, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query($data), - )); - - $json = curl_exec($curl); - curl_close($curl); - - if (!$json) { - throw new ZBException("No response"); - } - + $json = $this->readJsonBody($result); $response->Deserialize($json); - return 200; + return (int)$result['status']; } /** @@ -511,39 +488,21 @@ protected function formPost($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); } /** @@ -579,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 @@ -632,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( @@ -652,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())); } @@ -703,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']); } /** @@ -820,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])) { @@ -833,7 +751,7 @@ protected static function formatGetFileErrorMessage($body) return trim($o[$k][0]); } } - return $body; + return ZBHttpError::message(0, $body); } @@ -870,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 @@ -883,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 */ @@ -999,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; } @@ -1046,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; } @@ -1082,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 index 70eaef4..09317e7 100644 --- a/src/ZeroBouncePriority3.php +++ b/src/ZeroBouncePriority3.php @@ -22,9 +22,7 @@ public function sendEmailFinderFile( $fullNameColumn = null, $hasHeaderRow = null ) { - if (!$domainColumn) { - throw new ZBMissingParameterException('domainColumn is required'); - } + $this->requireProvided($domainColumn, 'domainColumn is required'); $fields = $this->buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow); return $this->sendBulkRootMultipartFile(self::EmailFinderBulkBaseUrl . '/sendfile', $filepath, $fields); } @@ -39,9 +37,7 @@ public function sendEmailFinderFileStream( $fullNameColumn = null, $hasHeaderRow = null ) { - if (!$domainColumn) { - throw new ZBMissingParameterException('domainColumn is required'); - } + $this->requireProvided($domainColumn, 'domainColumn is required'); $fields = $this->buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow); return $this->sendBulkRootMultipartContent(self::EmailFinderBulkBaseUrl . '/sendfile', $fileName, $streamContent, $fields); } @@ -63,24 +59,20 @@ public function emailFinderDeleteFile($fileId) public function sendDomainSearchFile($filepath, $domainColumn, $hasHeaderRow = null) { - if (!$domainColumn) { - throw new ZBMissingParameterException('domainColumn is required'); - } + $this->requireProvided($domainColumn, 'domainColumn is required'); $fields = array('domain_column' => $domainColumn); - if ($hasHeaderRow) { - $fields['has_header_row'] = 'true'; + 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) { - if (!$domainColumn) { - throw new ZBMissingParameterException('domainColumn is required'); - } + $this->requireProvided($domainColumn, 'domainColumn is required'); $fields = array('domain_column' => $domainColumn); - if ($hasHeaderRow) { - $fields['has_header_row'] = 'true'; + if ($hasHeaderRow !== null) { + $fields['has_header_row'] = $hasHeaderRow ? 'true' : 'false'; } return $this->sendBulkRootMultipartContent(self::DomainSearchBulkBaseUrl . '/sendfile', $fileName, $streamContent, $fields); } @@ -102,18 +94,14 @@ public function domainSearchDeleteFile($fileId) public function sendListEvaluatorFile($filepath, $emailAddressColumn) { - if (!$emailAddressColumn) { - throw new ZBMissingParameterException('emailAddressColumn is required'); - } + $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) { - if (!$emailAddressColumn) { - throw new ZBMissingParameterException('emailAddressColumn is required'); - } + $this->requireProvided($emailAddressColumn, 'emailAddressColumn is required'); $fields = array('email_address_column' => $emailAddressColumn); return $this->sendBulkRootMultipartContent(self::BulkApiBaseUrl . '/listevaluator/', $fileName, $streamContent, $fields); } @@ -124,9 +112,7 @@ public function sendListEvaluatorFileStream($streamContent, $fileName, $emailAdd public function getListEvaluatorResult($fileId) { $this->checkValidApiKey(); - if (!$fileId) { - throw new ZBMissingParameterException('fileId is required'); - } + $this->requireProvided($fileId, 'fileId is required'); $url = self::BulkApiBaseUrl . '/listevaluator/' . rawurlencode($fileId) . '/?api_key=' . urlencode($this->apiKey); $holder = new ZBRawJsonResponse(); $this->request($url, $holder); @@ -149,10 +135,10 @@ public function sendEvaluatedFileStream($streamContent, $fileName) public function addFilter($rule, $target, $value) { $this->checkValidApiKey(); - if (!$rule || !$target || !$value) { + if (!$this->isProvided($rule) || !$this->isProvided($target) || !$this->isProvided($value)) { throw new ZBMissingParameterException('rule, target, and value are required'); } - return $this->filterFormPost(self::$ApiBaseUrl . '/filters/add', array( + return $this->filterFormPost($this->apiBaseUrl . '/filters/add', array( 'rule' => $rule, 'target' => $target, 'value' => $value, @@ -166,9 +152,9 @@ public function listFilters($usePost = false) { $this->checkValidApiKey(); if ($usePost) { - return $this->filterFormPost(self::$ApiBaseUrl . '/filters/list', array()); + return $this->filterFormPost($this->apiBaseUrl . '/filters/list', array()); } - $url = self::$ApiBaseUrl . '/filters/list?api_key=' . urlencode($this->apiKey); + $url = $this->apiBaseUrl . '/filters/list?api_key=' . urlencode($this->apiKey); $holder = new ZBRawJsonResponse(); $this->request($url, $holder); return $holder->data; @@ -180,10 +166,10 @@ public function listFilters($usePost = false) public function deleteFilter($rule, $target, $value) { $this->checkValidApiKey(); - if (!$rule || !$target || !$value) { + if (!$this->isProvided($rule) || !$this->isProvided($target) || !$this->isProvided($value)) { throw new ZBMissingParameterException('rule, target, and value are required'); } - return $this->filterFormPost(self::$ApiBaseUrl . '/filters/delete', array( + return $this->filterFormPost($this->apiBaseUrl . '/filters/delete', array( 'rule' => $rule, 'target' => $target, 'value' => $value, @@ -210,7 +196,7 @@ public function listS3Files($bucket = null, $search = null) public function validateS3File(array $options) { - if (empty($options['file_name'])) { + if (!isset($options['file_name']) || !$this->isProvided($options['file_name'])) { throw new ZBMissingParameterException('file_name is required'); } return $this->postBulkRootForm('/validateS3file', $this->buildValidateS3Fields($options)); @@ -218,7 +204,7 @@ public function validateS3File(array $options) public function scoreS3File(array $options) { - if (empty($options['file_name'])) { + if (!isset($options['file_name']) || !$this->isProvided($options['file_name'])) { throw new ZBMissingParameterException('file_name is required'); } $fields = array('file_name' => $options['file_name']); @@ -233,12 +219,8 @@ public function scoreS3File(array $options) public function exportS3File($fileId, $exportBucket) { - if (!$fileId) { - throw new ZBMissingParameterException('fileId is required'); - } - if (!$exportBucket) { - throw new ZBMissingParameterException('exportBucket is required'); - } + $this->requireProvided($fileId, 'fileId is required'); + $this->requireProvided($exportBucket, 'exportBucket is required'); return $this->postBulkRootForm('/exportS3file', array( 'file_id' => $fileId, 'export_bucket' => $exportBucket, @@ -248,19 +230,19 @@ public function exportS3File($fileId, $exportBucket) private function buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow) { $fields = array('domain_column' => $domainColumn); - if ($hasHeaderRow) { - $fields['has_header_row'] = 'true'; + if ($hasHeaderRow !== null) { + $fields['has_header_row'] = $hasHeaderRow ? 'true' : 'false'; } - if ($firstNameColumn) { + if ($this->isProvided($firstNameColumn)) { $fields['first_name_column'] = $firstNameColumn; } - if ($lastNameColumn) { + if ($this->isProvided($lastNameColumn)) { $fields['last_name_column'] = $lastNameColumn; } - if ($middleNameColumn) { + if ($this->isProvided($middleNameColumn)) { $fields['middle_name_column'] = $middleNameColumn; } - if ($fullNameColumn) { + if ($this->isProvided($fullNameColumn)) { $fields['full_name_column'] = $fullNameColumn; } return $fields; @@ -278,16 +260,16 @@ private function buildValidateS3Fields(array $options) if (!empty($options['return_url'])) { $fields['return_url'] = $options['return_url']; } - if (!empty($options['first_name_column'])) { + if (isset($options['first_name_column']) && $this->isProvided($options['first_name_column'])) { $fields['first_name_column'] = $options['first_name_column']; } - if (!empty($options['last_name_column'])) { + if (isset($options['last_name_column']) && $this->isProvided($options['last_name_column'])) { $fields['last_name_column'] = $options['last_name_column']; } - if (!empty($options['gender_column'])) { + if (isset($options['gender_column']) && $this->isProvided($options['gender_column'])) { $fields['gender_column'] = $options['gender_column']; } - if (!empty($options['ip_address_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'])) { @@ -330,6 +312,7 @@ private function sendBulkRootMultipartContent($url, $fileName, $content, array $ private function postBulkRootForm($path, array $fields) { + $this->checkValidApiKey(); $rsp = new ZBSendFileResponse(); $this->formPost(self::BulkApiRootUrl . $path, $fields, $rsp); return $rsp; @@ -338,9 +321,7 @@ private function postBulkRootForm($path, array $fields) private function bulkRootFileStatus($baseUrl, $fileId) { $this->checkValidApiKey(); - if (!$fileId) { - throw new ZBMissingParameterException('fileId is required'); - } + $this->requireProvided($fileId, 'fileId is required'); $response = new ZBFileStatusResponse(); $this->request( $baseUrl . '/filestatus?api_key=' . urlencode($this->apiKey) . '&file_id=' . urlencode($fileId), @@ -352,9 +333,7 @@ private function bulkRootFileStatus($baseUrl, $fileId) private function bulkRootDeleteFile($baseUrl, $fileId) { $this->checkValidApiKey(); - if (!$fileId) { - throw new ZBMissingParameterException('fileId is required'); - } + $this->requireProvided($fileId, 'fileId is required'); $response = new ZBDeleteFileResponse(); $this->request( $baseUrl . '/deletefile?api_key=' . urlencode($this->apiKey) . '&file_id=' . urlencode($fileId), @@ -366,34 +345,10 @@ private function bulkRootDeleteFile($baseUrl, $fileId) private function bulkRootGetFile($baseUrl, $fileId, $downloadPath) { $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'); $url = $baseUrl . '/getfile?api_key=' . urlencode($this->apiKey) . '&file_id=' . urlencode($fileId); - $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); } /** 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).' ); } From 2a524fdd1c439a3c941ce8e23aa81cb4e02d38a6 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:39:25 +0400 Subject: [PATCH 7/7] docs: describe the PHP column and error behavior The notes claimed sendFile sent remove_duplicate and that optional column 0 was omitted. The SDK sends column 0 and has no remove_duplicate argument on sendFile. Co-authored-by: Cursor --- AGENTS.md | 46 ++++++++------- CHANGELOG.md | 160 ++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 152 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 58a58e0..638bfa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,39 +4,43 @@ Guidance for AI coding agents working in this repository. ## Project -`zero-bounce/sdk` is the ZeroBounce PHP SDK. Callers use `ZeroBounce::create()`, call `initialize` with an API key, and read the returned response object. Failures throw `ZBException` (or `ZBMissingApiKeyException` / `ZBMissingParameterException` before a request). HTTP goes through `executeHttp` in `src/ZeroBounce.php`. `src/ZBHttpError.php` turns HTML, empty bodies, and bad JSON into a short message. +`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`: public client. `executeHttp` is the lowest HTTP call. -- `src/ZeroBouncePriority3.php`: bulk email finder, domain search, list evaluator, filters, and S3. It is a trait on `ZeroBounce`. -- `src/ZB*.php`: response models and options. -- `test/OfflineApiTest.php` and `test/OfflineHttp.php`: offline suite. `OfflineClient` overrides `executeHttp`. No network and no real API key. -- `test/ZeroBounceTest.php`: existing PHPUnit cases with a scripted response. -- `composer.json`: the package version field, `"version": "x.y.z"`. `composer.lock` lists dependencies, not this package's version. There is no runtime version constant. +- `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; this is the syntax check -composer test # phpunit +composer lint # php -l on src and test +composer test # phpunit ``` -There is no separate typechecker. CI (`.github/workflows/sdk_ci.yml`) runs `php -l` and then `vendor/bin/phpunit`. PHPUnit collects every file in `test/`, including `OfflineApiTest.php`. +`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.** `ZeroBounce::Instance()` is a process-wide singleton. Do not reuse it across users or requests. Call `ZeroBounce::create()` for each key. The offline concurrency test does this. -- **Column indexes are 1-based and are not converted.** Optional name, gender, and IP columns use `0` to mean "leave this field out". Required `email_address_column` and `domain_column` are sent as given, including `0`. -- **Optional parameters are omitted when unset.** `timeout`, `activityData`, `verifyPlus`, `allowPhase2`, and `downloadType` are not sent until the caller sets them. Explicit `false` is sent as the string `false` on query and multipart bodies, and as a JSON boolean on `validateBatch`. -- **Booleans on multipart must be the strings `true` and `false`.** A PHP bool in a multipart part becomes `1` or `0`, and the API will not treat that as a boolean. +- **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. -- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. -- **Failures throw `ZBException`.** The message must be short: no raw HTML, no megabyte body, no parser exception text, and no `Error: Error:` prefix. An account-level `validateBatch` error (`email_address` `all`) throws. A per-address error stays on the result. -- **`executeHttp` does not follow redirects** (`CURLOPT_FOLLOWLOCATION` is false, and `file_get_contents` uses `follow_location` 0 and `max_redirects` 0). A 3xx response is an error. Do not turn redirects back on. -- **Tests override `executeHttp`.** It is `protected`. Subclass `ZeroBounce` in the test. Do not call the live API from `composer test`. -- Match the surrounding style. Do not reformat unrelated code. Do not commit `vendor/`, `.env`, or `.phpunit.cache`. +- **`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 @@ -52,6 +56,6 @@ There is no separate typechecker. CI (`.github/workflows/sdk_ci.yml`) runs `php 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": "x.y.z"`). That is the only package version field. The publish workflow fails if it differs from the tag. +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 publishes `zero-bounce/sdk` to Packagist. +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 index 86e45a6..8a793ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,34 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `validate` takes `ZBValidateOptions`: `timeout`, `activityData`, `verifyPlus`, and `usePost` (form-encoded `POST /validate`). -- `validateBatch` takes the same options. `timeout` is 10–120 seconds. -- `scoreEmail` (`GET /v2/scoring`) and a form-encoded `POST /v2/scoring` when `usePost` is set. -- Bulk email finder (`sendEmailFinderFile`, `emailFinderFileStatus`, `emailFinderGetFile`, `emailFinderDeleteFile`). -- Bulk domain search (`sendDomainSearchFile`, `domainSearchFileStatus`, `domainSearchGetFile`, `domainSearchDeleteFile`). -- List evaluator (`sendListEvaluatorFile`, `getListEvaluatorResult`, `sendEvaluatedFile`). -- Filters (`addFilter`, `listFilters`, `deleteFilter`). -- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3files`). - `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 and do not follow redirects. -- A custom base URL must be `https://`, except a test may point `getFile` at `http://127.0.0.1` or `http://localhost`. +- API requests time out after 120 seconds. +- Each `ZeroBounce` instance keeps its own API base URL. ### Fixed -- A 403 HTML "Access denied" page (about 1.4 MB from the live API on `getFile`) is thrown as a short `ZBException`. An HTML page with status 200 is an error as well, including `getFile`, `emailFinderGetFile`, and `domainSearchGetFile`, instead of a saved CSV or a JSON parse. -- Empty and non-JSON bodies are a short `ZBException`. The message is no longer raw parser text, and a repeated `Error:` / `[Error]:` prefix is removed. -- `validateBatch` throws when the body is an account-level failure (HTTP 200, error for address `all`, such as an invalid API key or no credits). An error for one address stays on the returned result. -- `sendFile` and `scoringSendFile` send `has_header_row` and `remove_duplicate` as `true` or `false`. Bulk email finder and domain search send `has_header_row` when it is set to false. -- Required column indexes are sent as given, including `0`. Optional name, gender, and IP columns of `0` are left out. -- `initialize('')` throws `ZBMissingApiKeyException` before a request is sent. +- 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 -- Query values are percent-encoded, and redirects are disabled, so a value cannot rewrite the request URL or send the client to another host. -- Error messages no longer include the raw HTML access-denied page. +- 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 @@ -48,27 +49,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Validate responses include `catchall_domain`. +- `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 -- HTTP helpers no longer read the deprecated `$http_response_header` on PHP 8.5. +- On PHP 8.5, reading response headers no longer touches the deprecated `$http_response_header` variable. ## [2.1.1] - 2026-04-15 ### Fixed -- `getFile` reads response headers on PHP 8.5 when `http_get_last_response_headers()` is not an array. +- `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 -- `sendFile` takes `allowPhase2`, sent as `allow_phase_2` on validation bulk only. -- `getFile` and `scoringGetFile` take `ZBGetFileOptions` with `downloadType` (`phase_1`, `phase_2`, `combined`). Validation `getFile` also takes `activityData`. -- `ZBFileStatusResponse` includes the phase 2 status. +- `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 @@ -76,47 +83,134 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 -- Response models for credits, API usage, batch validation, file status, and email finder match the 2.0 payloads. +- `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 -- `findEmailByDomain`, `findEmailByCompanyName`, and the format helpers. -- `initialize($apiKey, $baseUrl)` selects the default, US, or EU API host. +- `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 -- First stable release: `getCredits`, `getApiUsage`, `validate`, activity, and bulk file methods. +- `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.6...v2.1.9 +[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.3...v1.2.0 -[1.1.3]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.0...v1.1.3 -[1.1.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.0.0...v1.1.0 -[1.0.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/releases/tag/v1.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