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 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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 06/20] 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 07/20] 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 From b5cb941aa21b252d91c774ca1eb402f0c744b7d1 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:36:31 +0400 Subject: [PATCH 08/20] feat: validate WHOIS and greylist options, Inbox Placement Tester, list evaluator base URL Ports the Python SDK's parity changes (407f0a8, a6710e5) to this SDK. - ZBValidateOptions takes domainInfo, greylistProcessing and callbackUrl, sent as the strings "true"/"false" on both the GET and the form POST. callbackUrl without greylistProcessing = true, and a blank callbackUrl, are rejected before any request goes out. - ZBValidateResponse carries domainWebsiteExists, domainRegistrantCompanyName and message. ZBResponse already camel-cases the API's "Message" key onto that last property, which a greylist pickup returns while the retry runs. - Inbox Placement Tester as its own trait on bulkapi.zerobounce.net/api/ipt: providers, test types, create/get/list/delete tests, and get/set webhook. The API key goes on the query string rather than in the body, bodies are camelCase JSON, and only fields the caller set are sent. - List evaluator requests use their own base URL rather than building the path from the bulk base, and the file id is URL-encoded as one path segment. sendEvaluatedFile stays on the bulk base. initialize's third and fourth arguments override both base URLs. - An error body carrying a "detail" string (Django REST framework, used by the Inbox Placement Tester) raises with that text. Removes validateS3File, scoreS3File, exportS3File and listS3Files along with their helpers, the now-unused BulkApiRootUrl constant, tests and changelog entries: https://www.zerobounce.net/docs/ does not document those endpoints, and AGENTS.md now records that the SDK covers only documented ones. Tests: 62 pass (1 pre-existing skip). Co-Authored-By: Claude Opus 5 --- AGENTS.md | 12 +- CHANGELOG.md | 10 +- README.md | 59 ++++++++ documentation.md | 59 ++++++++ src/ZBHttpError.php | 2 +- src/ZBValidateOptions.php | 18 +++ src/ZBValidateResponse.php | 24 +++ src/ZeroBounce.php | 57 ++++++- src/ZeroBounceInboxPlacement.php | 243 +++++++++++++++++++++++++++++ src/ZeroBouncePriority3.php | 103 +------------ test/OfflineApiTest.php | 252 +++++++++++++++++++++++-------- test/OfflineHttp.php | 16 +- test/ZeroBounceTest.php | 21 --- 13 files changed, 683 insertions(+), 193 deletions(-) create mode 100644 src/ZeroBounceInboxPlacement.php diff --git a/AGENTS.md b/AGENTS.md index 638bfa6..c0659bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ Guidance for AI coding agents working in this repository. ## Layout - `src/ZeroBounce.php`: validation, bulk file, credits, activity, scoring. `executeHttp` is the HTTP call. -- `src/ZeroBouncePriority3.php`: bulk email finder, domain search, list evaluator, filters, S3. It is a trait on `ZeroBounce`. +- `src/ZeroBouncePriority3.php`: bulk email finder, domain search, list evaluator, filters. It is a trait on `ZeroBounce`. +- `src/ZeroBounceInboxPlacement.php`: Inbox Placement Tester. Also 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. @@ -27,14 +28,17 @@ composer test # phpunit ## Conventions +- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server. +- **The Inbox Placement Tester takes the API key as the `api_key` query parameter and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`. + - **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. +- **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. - **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. +- **`allow_phase_2` is validation `sendFile` only.** Scoring `sendFile` ignores it. `activity_data` on get-file is validation `getFile` only. - **`validate` timeout is 3–60 seconds. `validateBatch` timeout is 10–120.** An out-of-range timeout throws before a request. `null` omits `timeout`. -- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. Bulk file, email finder, domain search, list evaluator, and S3 use `bulkapi.zerobounce.net`. Filters and single-email calls use the host from `initialize`. +- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. Bulk file, email finder and domain search use `bulkapi.zerobounce.net`. The list evaluator and the Inbox Placement Tester have their own base URLs (`$listEvaluatorBaseUrl`, `$inboxPlacementBaseUrl`), set by `initialize`'s third and fourth arguments; `sendEvaluatedFile` stays on the bulk base. 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a793ca..f6eaf61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,22 +17,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). +- `validate` accepts `domainInfo`, `greylistProcessing`, and `callbackUrl` on `ZBValidateOptions`. `callbackUrl` needs `greylistProcessing = true` and is rejected without it, and when blank, before a request is sent. +- `ZBValidateResponse` carries `domainWebsiteExists`, `domainRegistrantCompanyName`, and `message`. `message` is read from the API's `Message` key, returned while greylist processing is still running. +- Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `getInboxPlacementProviders`, `getInboxPlacementTestTypes`, `createInboxPlacementTest`, `getInboxPlacementTest`, `listInboxPlacementTests`, `deleteInboxPlacementTest`, `getInboxPlacementWebhook`, `setInboxPlacementWebhook`. +- `initialize` takes optional third and fourth arguments to override the list evaluator and Inbox Placement Tester base URLs. ### Changed +- List evaluator requests use their own base URL (`https://bulkapi.zerobounce.net/v2/listevaluator`) instead of building the path from the bulk base, and the file id is URL-encoded as one path segment. `sendEvaluatedFile` stays on the bulk base. + - API requests time out after 120 seconds. - Each `ZeroBounce` instance keeps its own API base URL. ### Fixed +- An error body whose JSON carries a `detail` string (the Inbox Placement Tester, Django REST framework) raises with that text. + - 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 diff --git a/README.md b/README.md index cad28a7..0dd272f 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,65 @@ $response = ZeroBounce::Instance()->validate( $status = $response->status; ``` +- Validate with domain info and greylist processing: +```php +use ZeroBounce\SDK\ZBValidateOptions; + +$options = new ZBValidateOptions(); +// domainInfo adds domainWebsiteExists and domainRegistrantCompanyName. +$options->domainInfo = true; +// greylistProcessing re-runs a greylisted address instead of returning unknown. +$options->greylistProcessing = true; +// callbackUrl is only allowed together with greylistProcessing = true. +$options->callbackUrl = "https://example.com/zerobounce-callback"; + +$response = ZeroBounce::Instance()->validate("", null, $options); + +// While the greylist retry is still running the API returns a message +// instead of a result; validate again in about 30 minutes to pick it up. +echo $response->message; +echo $response->domainWebsiteExists; +``` + +- Inbox Placement Tester: +```php +// Lives on https://bulkapi.zerobounce.net/api/ipt and spends inbox test credits. +$providers = ZeroBounce::Instance()->getInboxPlacementProviders(); +$testTypes = ZeroBounce::Instance()->getInboxPlacementTestTypes(); + +// testType 1 (Email) needs a sender email; 2 is Unique ID and 3 is Auto Alias. +$created = ZeroBounce::Instance()->createInboxPlacementTest( + "October campaign", // required, at most 64 characters + 1, // testType + "news@example.com", // senderEmail, required for testType 1 + null, // providerIds + null, // seedIds + array(array("id" => 1, "maxSeeds" => 5)), // per-provider seed cap + "https://example.com/ipt-webhook" +); + +// Send your campaign to the seeds in $created->data, then read the results. +$result = ZeroBounce::Instance()->getInboxPlacementTest($created->data["id"]); + +$tests = ZeroBounce::Instance()->listInboxPlacementTests("finished", null, null, 1, 25); + +ZeroBounce::Instance()->setInboxPlacementWebhook("https://example.com/ipt-webhook"); +$webhook = ZeroBounce::Instance()->getInboxPlacementWebhook(); +ZeroBounce::Instance()->deleteInboxPlacementTest($created->data["id"]); +``` + +- Point an API at a different base URL: +```php +// The list evaluator and the Inbox Placement Tester have their own base URLs, +// separate from the main and bulk hosts. +ZeroBounce::Instance()->initialize( + "", + null, // main API base URL + "https://bulkapi.zerobounce.net/v2/listevaluator", // list evaluator + "https://bulkapi.zerobounce.net/api/ipt" // Inbox Placement Tester +); +``` + - Verify a batch of email addresses: ```php /** @var response ZeroBounce\SDK\ZBBatchValidateResponse */ diff --git a/documentation.md b/documentation.md index cbb8d0a..29a7255 100644 --- a/documentation.md +++ b/documentation.md @@ -53,6 +53,65 @@ $response = ZeroBounce::Instance()->validate( $status = $response->status; ``` +- Validate with domain info and greylist processing: +```php +use ZeroBounce\SDK\ZBValidateOptions; + +$options = new ZBValidateOptions(); +// domainInfo adds domainWebsiteExists and domainRegistrantCompanyName. +$options->domainInfo = true; +// greylistProcessing re-runs a greylisted address instead of returning unknown. +$options->greylistProcessing = true; +// callbackUrl is only allowed together with greylistProcessing = true. +$options->callbackUrl = "https://example.com/zerobounce-callback"; + +$response = ZeroBounce::Instance()->validate("", null, $options); + +// While the greylist retry is still running the API returns a message +// instead of a result; validate again in about 30 minutes to pick it up. +echo $response->message; +echo $response->domainWebsiteExists; +``` + +- Inbox Placement Tester: +```php +// Lives on https://bulkapi.zerobounce.net/api/ipt and spends inbox test credits. +$providers = ZeroBounce::Instance()->getInboxPlacementProviders(); +$testTypes = ZeroBounce::Instance()->getInboxPlacementTestTypes(); + +// testType 1 (Email) needs a sender email; 2 is Unique ID and 3 is Auto Alias. +$created = ZeroBounce::Instance()->createInboxPlacementTest( + "October campaign", // required, at most 64 characters + 1, // testType + "news@example.com", // senderEmail, required for testType 1 + null, // providerIds + null, // seedIds + array(array("id" => 1, "maxSeeds" => 5)), // per-provider seed cap + "https://example.com/ipt-webhook" +); + +// Send your campaign to the seeds in $created->data, then read the results. +$result = ZeroBounce::Instance()->getInboxPlacementTest($created->data["id"]); + +$tests = ZeroBounce::Instance()->listInboxPlacementTests("finished", null, null, 1, 25); + +ZeroBounce::Instance()->setInboxPlacementWebhook("https://example.com/ipt-webhook"); +$webhook = ZeroBounce::Instance()->getInboxPlacementWebhook(); +ZeroBounce::Instance()->deleteInboxPlacementTest($created->data["id"]); +``` + +- Point an API at a different base URL: +```php +// The list evaluator and the Inbox Placement Tester have their own base URLs, +// separate from the main and bulk hosts. +ZeroBounce::Instance()->initialize( + "", + null, // main API base URL + "https://bulkapi.zerobounce.net/v2/listevaluator", // list evaluator + "https://bulkapi.zerobounce.net/api/ipt" // Inbox Placement Tester +); +``` + - Verify a batch of email addresses: ```php /** @var response ZeroBounce\SDK\ZBBatchValidateResponse */ diff --git a/src/ZBHttpError.php b/src/ZBHttpError.php index 3d77933..e6a9385 100644 --- a/src/ZBHttpError.php +++ b/src/ZBHttpError.php @@ -154,7 +154,7 @@ private static function hasErrorOrMessageKey(array $decoded) private static function stringField(array $decoded) { - foreach (array('message', 'error', 'error_message') as $key) { + foreach (array('message', 'error', 'error_message', 'detail') as $key) { if (!isset($decoded[$key]) || $decoded[$key] === '' || $decoded[$key] === null || $decoded[$key] === array()) { continue; } diff --git a/src/ZBValidateOptions.php b/src/ZBValidateOptions.php index 4263aa7..5dc38c0 100644 --- a/src/ZBValidateOptions.php +++ b/src/ZBValidateOptions.php @@ -16,6 +16,24 @@ class ZBValidateOptions /** @var bool|null */ public $verifyPlus; + /** + * @var bool|null When true, the response adds domainWebsiteExists and + * domainRegistrantCompanyName (website availability and WHOIS). + */ + public $domainInfo; + + /** + * @var bool|null When true, a greylisted address is processed again instead of + * being returned as unknown. + */ + public $greylistProcessing; + + /** + * @var string|null With greylistProcessing = true, the URL the result is sent to. + * Rejected without it. + */ + public $callbackUrl; + /** @var bool When true, uses form-encoded POST for /validate. */ public $usePost = false; } diff --git a/src/ZBValidateResponse.php b/src/ZBValidateResponse.php index 470995f..aa1a111 100644 --- a/src/ZBValidateResponse.php +++ b/src/ZBValidateResponse.php @@ -136,6 +136,27 @@ class ZBValidateResponse extends ZBResponse */ public $processedAt; + /** + * With domainInfo: whether the domain has a website ("True", "False" or "unchecked"). + * @var string|null + */ + public $domainWebsiteExists; + + /** + * With domainInfo: the WHOIS registrant company, "Registrant info not found" + * or "unchecked". + * @var string|null + */ + public $domainRegistrantCompanyName; + + /** + * Set instead of a result while greylist processing is still running; validate + * again later to pick it up. Read from the API's "Message" key, which + * ZBResponse camel-cases to this property. + * @var string|null + */ + public $message; + /** * @var string|null */ @@ -191,6 +212,9 @@ public function __toString() "zipCode=" . $this->stringField($this->zipcode) . ", " . "country=" . $this->stringField($this->country) . ", " . "processedAt=" . $this->stringField($this->processedAt) . ", " . + "domainWebsiteExists=" . $this->stringField($this->domainWebsiteExists) . ", " . + "domainRegistrantCompanyName=" . $this->stringField($this->domainRegistrantCompanyName) . ", " . + "message=" . $this->stringField($this->message) . ", " . "error=" . $this->stringField($this->error) . "}"; } } diff --git a/src/ZeroBounce.php b/src/ZeroBounce.php index 6922388..e99e175 100644 --- a/src/ZeroBounce.php +++ b/src/ZeroBounce.php @@ -10,6 +10,7 @@ class ZeroBounce { use ZeroBouncePriority3; + use ZeroBounceInboxPlacement; /** * Call this method to get singleton @@ -47,16 +48,36 @@ protected function __construct() /** @var string */ protected $apiBaseUrl; const BulkApiBaseUrl = "https://bulkapi.zerobounce.net/v2"; + + /** The list evaluator and the Inbox Placement Tester are not under the bulk /v2 path. */ + const ListEvaluatorBaseUrl = "https://bulkapi.zerobounce.net/v2/listevaluator"; + const InboxPlacementBaseUrl = "https://bulkapi.zerobounce.net/api/ipt"; + + /** @var string Overridable per instance; defaults to ListEvaluatorBaseUrl. */ + protected $listEvaluatorBaseUrl = self::ListEvaluatorBaseUrl; + + /** @var string Overridable per instance; defaults to InboxPlacementBaseUrl. */ + protected $inboxPlacementBaseUrl = self::InboxPlacementBaseUrl; private $apiKey = null; /** * @param string $apiKey */ - public function initialize($apiKey, $baseUrl = null) - { + public function initialize( + $apiKey, + $baseUrl = null, + $listEvaluatorBaseUrl = null, + $inboxPlacementBaseUrl = null + ) { $this->apiKey = $apiKey; $this->apiBaseUrl = ZBBaseUrl::getByValue($baseUrl); + if ($listEvaluatorBaseUrl !== null && $listEvaluatorBaseUrl !== '') { + $this->listEvaluatorBaseUrl = rtrim($listEvaluatorBaseUrl, '/'); + } + if ($inboxPlacementBaseUrl !== null && $inboxPlacementBaseUrl !== '') { + $this->inboxPlacementBaseUrl = rtrim($inboxPlacementBaseUrl, '/'); + } } /** @@ -73,6 +94,16 @@ public function validate($email, $ipAddress = NULL, $options = NULL) if (!$email) throw new ZBMissingParameterException("email is required"); if ($options instanceof ZBValidateOptions) { $this->checkTimeout($options->timeout, false); + // callbackUrl only means anything while greylist processing is on, so + // reject it here rather than letting the API take the request. + if ($options->callbackUrl !== null && $options->greylistProcessing !== true) { + throw new ZBMissingParameterException( + "callbackUrl requires greylistProcessing = true" + ); + } + if ($options->callbackUrl !== null && trim((string)$options->callbackUrl) === '') { + throw new ZBMissingParameterException("callbackUrl is required"); + } } $response = new ZBValidateResponse(); $ipValue = ($ipAddress === null) ? '' : (string)$ipAddress; @@ -90,6 +121,15 @@ public function validate($email, $ipAddress = NULL, $options = NULL) if ($options->verifyPlus !== null) { $fields['verify_plus'] = $options->verifyPlus ? "true" : "false"; } + if ($options->domainInfo !== null) { + $fields['domain_info'] = $options->domainInfo ? "true" : "false"; + } + if ($options->greylistProcessing !== null) { + $fields['greylist_processing'] = $options->greylistProcessing ? "true" : "false"; + } + if ($options->callbackUrl !== null) { + $fields['callback_url'] = $options->callbackUrl; + } if ($options->usePost) { $this->formPost($this->apiBaseUrl . "/validate", $fields, $response); return $response; @@ -107,6 +147,15 @@ public function validate($email, $ipAddress = NULL, $options = NULL) if ($options->verifyPlus !== null) { $url .= "&verify_plus=" . ($options->verifyPlus ? "true" : "false"); } + if ($options->domainInfo !== null) { + $url .= "&domain_info=" . ($options->domainInfo ? "true" : "false"); + } + if ($options->greylistProcessing !== null) { + $url .= "&greylist_processing=" . ($options->greylistProcessing ? "true" : "false"); + } + if ($options->callbackUrl !== null) { + $url .= "&callback_url=" . urlencode($options->callbackUrl); + } } $this->request($url, $response); return $response; @@ -703,7 +752,7 @@ public static function getFileJsonIndicatesError($body) return true; } } - foreach (array('message', 'error', 'error_message') as $k) { + foreach (array('message', 'error', 'error_message', 'detail') as $k) { if (!empty($o[$k])) { if (is_string($o[$k]) && trim($o[$k]) !== '') { return true; @@ -740,7 +789,7 @@ protected static function formatGetFileErrorMessage($body) if (!is_array($o)) { return ZBHttpError::message(0, $body); } - foreach (array('message', 'error', 'error_message') as $k) { + foreach (array('message', 'error', 'error_message', 'detail') as $k) { if (empty($o[$k])) { continue; } diff --git a/src/ZeroBounceInboxPlacement.php b/src/ZeroBounceInboxPlacement.php new file mode 100644 index 0000000..cc44540 --- /dev/null +++ b/src/ZeroBounceInboxPlacement.php @@ -0,0 +1,243 @@ + */ + private static $iptTestTypes = array(1, 2, 3); + + /** @var array */ + private static $iptTestStatuses = array('running', 'finished'); + + /** + * Seed providers available for new tests. GET /providers + * + * @return ZBRawJsonResponse + * @throws ZBException + */ + public function getInboxPlacementProviders() + { + return $this->iptRequest('GET', '/providers'); + } + + /** + * Supported test types and the default one. GET /test-types + * + * @return ZBRawJsonResponse + * @throws ZBException + */ + public function getInboxPlacementTestTypes() + { + return $this->iptRequest('GET', '/test-types'); + } + + /** + * Create an inbox placement test. POST /test + * + * @param string $name Unique test name, at most 64 characters. + * @param int|null $testType 1 (Email, needs $senderEmail), 2 (Unique ID) or 3 (Auto Alias). + * @param string|null $senderEmail The From address to match; required when $testType is 1. + * @param array|null $providerIds Use every active seed of these providers. + * @param array|null $seedIds Use these seed mailboxes. + * @param array>|null $providers array('id' => n, 'maxSeeds' => n). + * @param string|null $webhookUrl Where the finished result is posted. + * @return ZBRawJsonResponse + * @throws ZBException + */ + public function createInboxPlacementTest( + $name, + $testType = null, + $senderEmail = null, + $providerIds = null, + $seedIds = null, + $providers = null, + $webhookUrl = null + ) { + if ($name === null || trim((string)$name) === '') { + throw new ZBMissingParameterException("name is required"); + } + if (strlen((string)$name) > 64) { + throw new ZBMissingParameterException("name must be at most 64 characters"); + } + if ($testType !== null && !in_array((int)$testType, self::$iptTestTypes, true)) { + throw new ZBMissingParameterException("testType must be 1, 2 or 3"); + } + if ((int)$testType === 1 && ($senderEmail === null || trim((string)$senderEmail) === '')) { + throw new ZBMissingParameterException("senderEmail is required when testType is 1"); + } + foreach (($providers === null ? array() : $providers) as $provider) { + if (!is_array($provider) || !isset($provider['id'])) { + throw new ZBMissingParameterException("each providers entry needs an id"); + } + if (isset($provider['maxSeeds']) && (int)$provider['maxSeeds'] < 1) { + throw new ZBMissingParameterException("providers maxSeeds must be at least 1"); + } + } + + $body = array('name' => $name); + if ($testType !== null) { + $body['testType'] = (int)$testType; + } + if ($senderEmail !== null) { + $body['senderEmail'] = $senderEmail; + } + if ($providerIds !== null) { + $body['providerIds'] = array_values($providerIds); + } + if ($seedIds !== null) { + $body['seedIds'] = array_values($seedIds); + } + if ($providers !== null) { + $body['providers'] = array_values($providers); + } + if ($webhookUrl !== null) { + $body['webhookUrl'] = $webhookUrl; + } + return $this->iptRequest('POST', '/test', array(), $body); + } + + /** + * A test and its placement results. GET /test/{id} + * + * @param string|int $testId + * @return ZBRawJsonResponse + * @throws ZBException + */ + public function getInboxPlacementTest($testId) + { + return $this->iptRequest('GET', '/test/' . $this->iptTestPath($testId)); + } + + /** + * List tests, newest first. GET /tests + * + * @param string|null $status "running" or "finished". + * @param string|null $createdAfter ISO 8601 date or datetime. + * @param string|null $createdBefore ISO 8601 date or datetime. + * @param int|null $page From 1. + * @param int|null $pageSize 1 to 100. + * @return ZBRawJsonResponse + * @throws ZBException + */ + public function listInboxPlacementTests( + $status = null, + $createdAfter = null, + $createdBefore = null, + $page = null, + $pageSize = null + ) { + if ($status !== null && !in_array($status, self::$iptTestStatuses, true)) { + throw new ZBMissingParameterException("status must be running or finished"); + } + if ($page !== null && (int)$page < 1) { + throw new ZBMissingParameterException("page must be at least 1"); + } + if ($pageSize !== null && ((int)$pageSize < 1 || (int)$pageSize > 100)) { + throw new ZBMissingParameterException("pageSize must be between 1 and 100"); + } + + $query = array(); + if ($status !== null) { + $query['status'] = $status; + } + if ($createdAfter !== null) { + $query['createdAfter'] = $createdAfter; + } + if ($createdBefore !== null) { + $query['createdBefore'] = $createdBefore; + } + if ($page !== null) { + $query['page'] = (int)$page; + } + if ($pageSize !== null) { + $query['page_size'] = (int)$pageSize; + } + return $this->iptRequest('GET', '/tests', $query); + } + + /** + * Delete a finished test. DELETE /test/{id} + * + * @param string|int $testId + * @return ZBRawJsonResponse + * @throws ZBException + */ + public function deleteInboxPlacementTest($testId) + { + return $this->iptRequest('DELETE', '/test/' . $this->iptTestPath($testId)); + } + + /** + * The webhook URL saved on this API key. GET /webhook + * + * @return ZBRawJsonResponse + * @throws ZBException + */ + public function getInboxPlacementWebhook() + { + return $this->iptRequest('GET', '/webhook'); + } + + /** + * Save the URL finished results are posted to. PUT /webhook + * + * @param string $webhookUrl + * @return ZBRawJsonResponse + * @throws ZBException + */ + public function setInboxPlacementWebhook($webhookUrl) + { + if ($webhookUrl === null || trim((string)$webhookUrl) === '') { + throw new ZBMissingParameterException("webhookUrl is required"); + } + return $this->iptRequest('PUT', '/webhook', array(), array('webhookUrl' => $webhookUrl)); + } + + /** + * @param string|int $testId + * @return string + * @throws ZBException + */ + private function iptTestPath($testId) + { + if ($testId === null || trim((string)$testId) === '') { + throw new ZBMissingParameterException("testId is required"); + } + return rawurlencode(trim((string)$testId)); + } + + /** + * @param string $method + * @param string $path + * @param array $query + * @param array|null $body JSON body, sent without api_key. + * @return ZBRawJsonResponse + * @throws ZBException + */ + private function iptRequest($method, $path, array $query = array(), $body = null) + { + $this->checkValidApiKey(); + $query = array_merge(array('api_key' => $this->apiKey), $query); + $url = $this->inboxPlacementBaseUrl . $path . '?' . http_build_query($query); + + $headers = array(); + $content = null; + if ($body !== null) { + $headers[] = 'Content-Type: application/json'; + $content = json_encode($body); + } + + $result = $this->executeHttp($method, $url, $headers, $content, 120); + $response = new ZBRawJsonResponse(); + $response->Deserialize($this->readJsonBody($result)); + return $response; + } +} diff --git a/src/ZeroBouncePriority3.php b/src/ZeroBouncePriority3.php index 09317e7..1edd466 100644 --- a/src/ZeroBouncePriority3.php +++ b/src/ZeroBouncePriority3.php @@ -5,11 +5,10 @@ use Exception; /** - * Priority 3 API: bulk email finder, domain search, list evaluator, filters, S3 helpers. + * Priority 3 API: bulk email finder, domain search, list evaluator, filters. */ trait ZeroBouncePriority3 { - private const BulkApiRootUrl = 'https://bulkapi.zerobounce.net'; private const EmailFinderBulkBaseUrl = 'https://bulkapi.zerobounce.net/email-finder'; private const DomainSearchBulkBaseUrl = 'https://bulkapi.zerobounce.net/domain-search'; @@ -96,14 +95,14 @@ public function sendListEvaluatorFile($filepath, $emailAddressColumn) { $this->requireProvided($emailAddressColumn, 'emailAddressColumn is required'); $fields = array('email_address_column' => $emailAddressColumn); - return $this->sendBulkRootMultipartFile(self::BulkApiBaseUrl . '/listevaluator/', $filepath, $fields); + return $this->sendBulkRootMultipartFile($this->listEvaluatorBaseUrl . '/', $filepath, $fields); } public function sendListEvaluatorFileStream($streamContent, $fileName, $emailAddressColumn) { $this->requireProvided($emailAddressColumn, 'emailAddressColumn is required'); $fields = array('email_address_column' => $emailAddressColumn); - return $this->sendBulkRootMultipartContent(self::BulkApiBaseUrl . '/listevaluator/', $fileName, $streamContent, $fields); + return $this->sendBulkRootMultipartContent($this->listEvaluatorBaseUrl . '/', $fileName, $streamContent, $fields); } /** @@ -113,7 +112,7 @@ public function getListEvaluatorResult($fileId) { $this->checkValidApiKey(); $this->requireProvided($fileId, 'fileId is required'); - $url = self::BulkApiBaseUrl . '/listevaluator/' . rawurlencode($fileId) . '/?api_key=' . urlencode($this->apiKey); + $url = $this->listEvaluatorBaseUrl . '/' . rawurlencode($fileId) . '/?api_key=' . urlencode($this->apiKey); $holder = new ZBRawJsonResponse(); $this->request($url, $holder); return $holder->data; @@ -176,57 +175,6 @@ 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 (!isset($options['file_name']) || !$this->isProvided($options['file_name'])) { - throw new ZBMissingParameterException('file_name is required'); - } - return $this->postBulkRootForm('/validateS3file', $this->buildValidateS3Fields($options)); - } - - public function scoreS3File(array $options) - { - if (!isset($options['file_name']) || !$this->isProvided($options['file_name'])) { - throw new ZBMissingParameterException('file_name is required'); - } - $fields = array('file_name' => $options['file_name']); - $this->appendOptionalBool($fields, 'has_header_row', isset($options['has_header_row']) ? $options['has_header_row'] : null); - $this->appendOptionalBool($fields, 'remove_duplicates', isset($options['remove_duplicates']) ? $options['remove_duplicates'] : null); - $this->appendOptionalBool($fields, 'remove_duplicate', isset($options['remove_duplicate']) ? $options['remove_duplicate'] : null); - if (isset($options['email_address_column'])) { - $fields['email_address_column'] = $options['email_address_column']; - } - return $this->postBulkRootForm('/scoreS3file', $fields); - } - - public function exportS3File($fileId, $exportBucket) - { - $this->requireProvided($fileId, 'fileId is required'); - $this->requireProvided($exportBucket, 'exportBucket is required'); - return $this->postBulkRootForm('/exportS3file', array( - 'file_id' => $fileId, - 'export_bucket' => $exportBucket, - )); - } - private function buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow) { $fields = array('domain_column' => $domainColumn); @@ -248,42 +196,6 @@ private function buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNa return $fields; } - private function buildValidateS3Fields(array $options) - { - $fields = array('file_name' => $options['file_name']); - $this->appendOptionalBool($fields, 'has_header_row', isset($options['has_header_row']) ? $options['has_header_row'] : null); - $this->appendOptionalBool($fields, 'remove_duplicates', isset($options['remove_duplicates']) ? $options['remove_duplicates'] : null); - $this->appendOptionalBool($fields, 'remove_duplicate', isset($options['remove_duplicate']) ? $options['remove_duplicate'] : null); - if (isset($options['email_address_column'])) { - $fields['email_address_column'] = $options['email_address_column']; - } - if (!empty($options['return_url'])) { - $fields['return_url'] = $options['return_url']; - } - if (isset($options['first_name_column']) && $this->isProvided($options['first_name_column'])) { - $fields['first_name_column'] = $options['first_name_column']; - } - if (isset($options['last_name_column']) && $this->isProvided($options['last_name_column'])) { - $fields['last_name_column'] = $options['last_name_column']; - } - if (isset($options['gender_column']) && $this->isProvided($options['gender_column'])) { - $fields['gender_column'] = $options['gender_column']; - } - if (isset($options['ip_address_column']) && $this->isProvided($options['ip_address_column'])) { - $fields['ip_address_column'] = $options['ip_address_column']; - } - if (isset($options['allow_phase_2'])) { - $fields['allow_phase_2'] = $options['allow_phase_2'] ? 'true' : 'false'; - } - return $fields; - } - - private function appendOptionalBool(array &$fields, $key, $value) - { - if ($value !== null) { - $fields[$key] = $value ? 'true' : 'false'; - } - } private function sendBulkRootMultipartFile($url, $filepath, array $fields) { @@ -310,13 +222,6 @@ private function sendBulkRootMultipartContent($url, $fileName, $content, array $ return $rsp; } - private function postBulkRootForm($path, array $fields) - { - $this->checkValidApiKey(); - $rsp = new ZBSendFileResponse(); - $this->formPost(self::BulkApiRootUrl . $path, $fields, $rsp); - return $rsp; - } private function bulkRootFileStatus($baseUrl, $fileId) { diff --git a/test/OfflineApiTest.php b/test/OfflineApiTest.php index 033266d..72408b1 100644 --- a/test/OfflineApiTest.php +++ b/test/OfflineApiTest.php @@ -470,53 +470,6 @@ public function testPriority3EndpointsAndZeroColumn() $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() @@ -542,15 +495,6 @@ function () use ($client) { 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); }, @@ -591,9 +535,6 @@ function () use ($client) { $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'); @@ -900,6 +841,199 @@ public function testRedirectsAreNotFollowed() } } + public function testValidateSendsTheNewOptionsOnlyWhenSet() + { + $api = new FakeApi(); + $client = $this->client($api, 'p1-key'); + + $client->validate('a@example.com'); + $plain = $this->only($api); + $this->assertArrayNotHasKey('domain_info', $plain['fields']); + $this->assertArrayNotHasKey('greylist_processing', $plain['fields']); + $this->assertArrayNotHasKey('callback_url', $plain['fields']); + + $api->clear(); + $options = new ZBValidateOptions(); + $options->domainInfo = true; + $options->greylistProcessing = false; + $client->validate('a@example.com', null, $options); + $set = $this->only($api); + $this->assertSame('true', (string)$set['fields']['domain_info']); + $this->assertSame('false', (string)$set['fields']['greylist_processing']); + + $api->clear(); + $options = new ZBValidateOptions(); + $options->greylistProcessing = true; + $options->callbackUrl = 'https://example.com/cb'; + $options->usePost = true; + $client->validate('a@example.com', null, $options); + $posted = $this->only($api); + $this->assertSame('POST', $posted['method']); + $this->assertSame('true', (string)$posted['fields']['greylist_processing']); + $this->assertSame('https://example.com/cb', $posted['fields']['callback_url']); + } + + public function testValidateRejectsCallbackUrlWithoutGreylistProcessing() + { + $api = new FakeApi(); + $client = $this->client($api, 'p1-key'); + + $options = new ZBValidateOptions(); + $options->callbackUrl = 'https://example.com/cb'; + $this->assertRejected(function () use ($client, $options) { + $client->validate('a@example.com', null, $options); + }, 'greylistProcessing'); + + $blank = new ZBValidateOptions(); + $blank->greylistProcessing = true; + $blank->callbackUrl = ' '; + $this->assertRejected(function () use ($client, $blank) { + $client->validate('a@example.com', null, $blank); + }, 'callbackUrl'); + + $this->assertSame(0, $api->count()); + } + + public function testInboxPlacementUsesItsOwnHostAndCamelCaseJson() + { + $api = new FakeApi(); + $client = $this->client($api, 'ipt-key'); + + $client->getInboxPlacementProviders(); + $providers = $this->only($api); + $this->assertSame('GET', $providers['method']); + $this->assertSame('bulkapi.zerobounce.net', $providers['host']); + $this->assertSame('/api/ipt/providers', $providers['path']); + $this->assertSame('ipt-key', $providers['fields']['api_key']); + + $api->clear(); + $client->getInboxPlacementTestTypes(); + $this->assertSame('/api/ipt/test-types', $this->only($api)['path']); + + $api->clear(); + $client->createInboxPlacementTest('basic'); + $created = $this->only($api); + $this->assertSame('POST', $created['method']); + $this->assertSame('/api/ipt/test', $created['path']); + $this->assertSame(array('name' => 'basic'), $created['json']); + + $api->clear(); + $client->createInboxPlacementTest( + 'full', + 1, + 'from@example.com', + array(1, 2), + array(3), + array(array('id' => 4, 'maxSeeds' => 2)), + 'https://example.com/hook' + ); + $this->assertSame(array( + 'name' => 'full', + 'testType' => 1, + 'senderEmail' => 'from@example.com', + 'providerIds' => array(1, 2), + 'seedIds' => array(3), + 'providers' => array(array('id' => 4, 'maxSeeds' => 2)), + 'webhookUrl' => 'https://example.com/hook', + ), $this->only($api)['json']); + + $api->clear(); + $client->getInboxPlacementTest('a b/c'); + $this->assertSame('/api/ipt/test/a%20b%2Fc', $this->only($api)['path']); + + $api->clear(); + $client->deleteInboxPlacementTest('t1'); + $this->assertSame('DELETE', $this->only($api)['method']); + + $api->clear(); + $client->setInboxPlacementWebhook('https://example.com/hook'); + $webhook = $this->only($api); + $this->assertSame('PUT', $webhook['method']); + $this->assertSame(array('webhookUrl' => 'https://example.com/hook'), $webhook['json']); + } + + public function testInboxPlacementListSendsOnlyTheFiltersThatAreSet() + { + $api = new FakeApi(); + $client = $this->client($api, 'ipt-key'); + + $client->listInboxPlacementTests(); + $this->assertSame(array('api_key'), array_keys($this->only($api)['fields'])); + + $api->clear(); + $client->listInboxPlacementTests('finished', '2026-01-01', '2026-02-01', 2, 50); + $fields = $this->only($api)['fields']; + $this->assertSame('finished', $fields['status']); + $this->assertSame('2026-01-01', $fields['createdAfter']); + $this->assertSame('2026-02-01', $fields['createdBefore']); + $this->assertSame('2', (string)$fields['page']); + $this->assertSame('50', (string)$fields['page_size']); + } + + public function testInboxPlacementRejectsInvalidArgumentsBeforeRequest() + { + $api = new FakeApi(); + $client = $this->client($api, 'ipt-key'); + + $cases = array( + function () use ($client) { $client->createInboxPlacementTest(''); }, + function () use ($client) { $client->createInboxPlacementTest(str_repeat('x', 65)); }, + function () use ($client) { $client->createInboxPlacementTest('t', 4); }, + function () use ($client) { $client->createInboxPlacementTest('t', 1); }, + function () use ($client) { $client->createInboxPlacementTest('t', null, null, null, null, array(array('maxSeeds' => 2))); }, + function () use ($client) { $client->createInboxPlacementTest('t', null, null, null, null, array(array('id' => 1, 'maxSeeds' => 0))); }, + function () use ($client) { $client->listInboxPlacementTests('queued'); }, + function () use ($client) { $client->listInboxPlacementTests(null, null, null, 0); }, + function () use ($client) { $client->listInboxPlacementTests(null, null, null, null, 101); }, + function () use ($client) { $client->getInboxPlacementTest(''); }, + function () use ($client) { $client->deleteInboxPlacementTest(''); }, + function () use ($client) { $client->setInboxPlacementWebhook(''); }, + ); + foreach ($cases as $case) { + $threw = false; + try { + $case(); + } catch (ZBException $e) { + $threw = true; + } + $this->assertTrue($threw); + } + $this->assertSame(0, $api->count()); + } + + public function testInboxPlacementDetailErrorBecomesTheMessage() + { + $api = new FakeApi(); + $client = $this->client($api, 'ipt-detail-key'); + + try { + $client->getInboxPlacementProviders(); + $this->fail('expected a ZBException'); + } catch (ZBException $e) { + $this->assertStringContainsString('Inbox placement test not found', $e->getMessage()); + $this->assertLessThan(500, strlen($e->getMessage())); + } + } + + public function testListEvaluatorUsesItsOwnBaseUrlAndEncodesFileId() + { + $api = new FakeApi(); + $client = $this->client($api, 'le-key'); + + $client->getListEvaluatorResult('a b/c'); + $result = $this->only($api); + $this->assertSame('bulkapi.zerobounce.net', $result['host']); + $this->assertSame('/v2/listevaluator/a%20b%2Fc/', $result['path']); + + $api->clear(); + $overridden = new OfflineClient($api); + $overridden->initialize('le-key', null, 'https://example.com/custom/listevaluator'); + $overridden->getListEvaluatorResult('fid'); + $custom = $this->only($api); + $this->assertSame('example.com', $custom['host']); + $this->assertSame('/custom/listevaluator/fid/', $custom['path']); + } + private function client(FakeApi $api, $key) { $client = new OfflineClient($api); diff --git a/test/OfflineHttp.php b/test/OfflineHttp.php index cfa8d84..06b7088 100644 --- a/test/OfflineHttp.php +++ b/test/OfflineHttp.php @@ -52,6 +52,11 @@ public function handle($method, $url, array $headers, $body, $timeout) 'body' => $body === null ? '' : $body, 'content_type' => $contentType, 'fields' => $fields, + 'json' => ( + $body !== null && $body !== '' && stripos((string)$contentType, 'application/json') !== false + ? json_decode((string)$body, true) + : null + ), 'api_key' => $apiKey, 'timeout' => $timeout, ); @@ -88,6 +93,14 @@ private function answer(array $request) { $key = $request['api_key']; $path = $request['path']; + $isInboxPlacement = strpos($path, '/api/ipt/') !== false || substr($path, -8) === '/api/ipt'; + if ($isInboxPlacement && strpos($key, 'ipt-detail') === 0) { + // The Inbox Placement Tester (Django REST framework) reports "detail". + return $this->respond(404, 'application/json', '{"detail":"Inbox placement test not found."}'); + } + if ($isInboxPlacement) { + return $this->respond(200, 'application/json', '{"ok":true,"echo_api_key":' . json_encode($key) . '}'); + } 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()); @@ -140,9 +153,6 @@ private function answer(array $request) 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":[]}'); } diff --git a/test/ZeroBounceTest.php b/test/ZeroBounceTest.php index b1c176f..ffcecd8 100644 --- a/test/ZeroBounceTest.php +++ b/test/ZeroBounceTest.php @@ -737,25 +737,4 @@ public function testSendEmailFinderFileUsesBulkEmailFinderEndpoint() } } - public function testValidateS3FilePostsToBulkRoot() - { - ZeroBounce::Instance()->responseText = '{ - "success": true, - "file_id": "s3-job", - "message": "File Accepted" - }'; - ZeroBounce::Instance()->validateS3File(array('file_name' => 'bucket/key.csv')); - $this->assertStringContainsString('bulkapi.zerobounce.net/validateS3file', ZeroBounce::Instance()->lastFormPostUrl); - } - - public function testListS3FilesCallsBulkListS3files() - { - ZeroBounce::Instance()->responseText = '[{"file_name":"bucket/key.csv","size":1024}]'; - $result = ZeroBounce::Instance()->listS3Files('my-bucket', 'key'); - $this->assertStringContainsString('bulkapi.zerobounce.net/listS3files', ZeroBounce::Instance()->lastRequestUrl); - $this->assertStringContainsString('bucket=my-bucket', ZeroBounce::Instance()->lastRequestUrl); - $this->assertStringContainsString('search=key', ZeroBounce::Instance()->lastRequestUrl); - $this->assertIsArray($result); - $this->assertEquals('bucket/key.csv', $result[0]['file_name']); - } } From 77d6865d78936a2530904b3b652c6260ffef6488 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:38:53 +0400 Subject: [PATCH 09/20] revert: restore the S3 bulk methods They are documented, at https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, which lists listS3files, validateS3file, scoreS3file, exportS3file, emailFinderS3file and domainSearchS3file on bulkapi.zerobounce.net. The previous commit removed them on the incorrect basis that the public docs do not cover them; only the main API reference was considered. Restores validateS3File, scoreS3File, exportS3File and listS3Files, the buildValidateS3Fields, postBulkRootForm and appendOptionalBool helpers, the BulkApiRootUrl constant, the offline and legacy tests, and the changelog entries. The parity work in the previous commit is untouched. AGENTS.md keeps the rule that the SDK covers only documented endpoints and now points at where the S3 documentation lives. Not added here: emailFinderS3file and domainSearchS3file, and the SFTP variants listed on the Integrations index, are documented but have never been part of this SDK. Tests: 64 pass, 1 pre-existing skip. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 10 ++-- CHANGELOG.md | 2 + src/ZeroBouncePriority3.php | 99 ++++++++++++++++++++++++++++++++++++- test/OfflineApiTest.php | 60 ++++++++++++++++++++++ test/OfflineHttp.php | 3 ++ test/ZeroBounceTest.php | 22 +++++++++ 6 files changed, 190 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c0659bb..ed12ca8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Guidance for AI coding agents working in this repository. ## Layout - `src/ZeroBounce.php`: validation, bulk file, credits, activity, scoring. `executeHttp` is the HTTP call. -- `src/ZeroBouncePriority3.php`: bulk email finder, domain search, list evaluator, filters. It is a trait on `ZeroBounce`. +- `src/ZeroBouncePriority3.php`: bulk email finder, domain search, list evaluator, filters, S3. It is a trait on `ZeroBounce`. - `src/ZeroBounceInboxPlacement.php`: Inbox Placement Tester. Also 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`. @@ -28,17 +28,17 @@ composer test # phpunit ## Conventions -- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server. +- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 bulk endpoints live under https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, not the main API reference. - **The Inbox Placement Tester takes the API key as the `api_key` query parameter and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`. - **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. +- **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` only.** Scoring `sendFile` ignores it. `activity_data` on get-file is validation `getFile` only. +- **`allow_phase_2` is validation `sendFile` and `validateS3File` only.** Scoring `sendFile` ignores it. `activity_data` on get-file is validation `getFile` only. - **`validate` timeout is 3–60 seconds. `validateBatch` timeout is 10–120.** An out-of-range timeout throws before a request. `null` omits `timeout`. -- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. Bulk file, email finder and domain search use `bulkapi.zerobounce.net`. The list evaluator and the Inbox Placement Tester have their own base URLs (`$listEvaluatorBaseUrl`, `$inboxPlacementBaseUrl`), set by `initialize`'s third and fourth arguments; `sendEvaluatedFile` stays on the bulk base. Filters and single-email calls use the host from `initialize`. +- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. Bulk file, email finder, domain search and S3 use `bulkapi.zerobounce.net`. The list evaluator and the Inbox Placement Tester have their own base URLs (`$listEvaluatorBaseUrl`, `$inboxPlacementBaseUrl`), set by `initialize`'s third and fourth arguments; `sendEvaluatedFile` stays on the bulk base. 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index f6eaf61..dee1891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bulk domain search (`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`). - `validate` accepts `domainInfo`, `greylistProcessing`, and `callbackUrl` on `ZBValidateOptions`. `callbackUrl` needs `greylistProcessing = true` and is rejected without it, and when blank, before a request is sent. - `ZBValidateResponse` carries `domainWebsiteExists`, `domainRegistrantCompanyName`, and `message`. `message` is read from the API's `Message` key, returned while greylist processing is still running. - Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `getInboxPlacementProviders`, `getInboxPlacementTestTypes`, `createInboxPlacementTest`, `getInboxPlacementTest`, `listInboxPlacementTests`, `deleteInboxPlacementTest`, `getInboxPlacementWebhook`, `setInboxPlacementWebhook`. @@ -31,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `validateS3File`, `scoreS3File`, and `exportS3File` reject an empty API key before a request is sent. - An error body whose JSON carries a `detail` string (the Inbox Placement Tester, Django REST framework) raises with that text. - 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. diff --git a/src/ZeroBouncePriority3.php b/src/ZeroBouncePriority3.php index 1edd466..26a44ec 100644 --- a/src/ZeroBouncePriority3.php +++ b/src/ZeroBouncePriority3.php @@ -5,10 +5,11 @@ use Exception; /** - * Priority 3 API: bulk email finder, domain search, list evaluator, filters. + * Priority 3 API: bulk email finder, domain search, list evaluator, filters, S3 helpers. */ trait ZeroBouncePriority3 { + private const BulkApiRootUrl = 'https://bulkapi.zerobounce.net'; private const EmailFinderBulkBaseUrl = 'https://bulkapi.zerobounce.net/email-finder'; private const DomainSearchBulkBaseUrl = 'https://bulkapi.zerobounce.net/domain-search'; @@ -131,6 +132,57 @@ public function sendEvaluatedFileStream($streamContent, $fileName) /** * @return array */ + /** + * @return array> + */ + public function listS3Files($bucket = null, $search = null) + { + $this->checkValidApiKey(); + $url = self::BulkApiRootUrl . '/listS3files?api_key=' . urlencode($this->apiKey); + if ($bucket !== null && $bucket !== '') { + $url .= '&bucket=' . urlencode($bucket); + } + if ($search !== null && $search !== '') { + $url .= '&search=' . urlencode($search); + } + $holder = new ZBRawJsonResponse(); + $this->request($url, $holder); + return $holder->data; + } + + public function validateS3File(array $options) + { + if (!isset($options['file_name']) || !$this->isProvided($options['file_name'])) { + throw new ZBMissingParameterException('file_name is required'); + } + return $this->postBulkRootForm('/validateS3file', $this->buildValidateS3Fields($options)); + } + + public function scoreS3File(array $options) + { + if (!isset($options['file_name']) || !$this->isProvided($options['file_name'])) { + throw new ZBMissingParameterException('file_name is required'); + } + $fields = array('file_name' => $options['file_name']); + $this->appendOptionalBool($fields, 'has_header_row', isset($options['has_header_row']) ? $options['has_header_row'] : null); + $this->appendOptionalBool($fields, 'remove_duplicates', isset($options['remove_duplicates']) ? $options['remove_duplicates'] : null); + $this->appendOptionalBool($fields, 'remove_duplicate', isset($options['remove_duplicate']) ? $options['remove_duplicate'] : null); + if (isset($options['email_address_column'])) { + $fields['email_address_column'] = $options['email_address_column']; + } + return $this->postBulkRootForm('/scoreS3file', $fields); + } + + public function exportS3File($fileId, $exportBucket) + { + $this->requireProvided($fileId, 'fileId is required'); + $this->requireProvided($exportBucket, 'exportBucket is required'); + return $this->postBulkRootForm('/exportS3file', array( + 'file_id' => $fileId, + 'export_bucket' => $exportBucket, + )); + } + public function addFilter($rule, $target, $value) { $this->checkValidApiKey(); @@ -175,6 +227,51 @@ public function deleteFilter($rule, $target, $value) )); } + private function buildValidateS3Fields(array $options) + { + $fields = array('file_name' => $options['file_name']); + $this->appendOptionalBool($fields, 'has_header_row', isset($options['has_header_row']) ? $options['has_header_row'] : null); + $this->appendOptionalBool($fields, 'remove_duplicates', isset($options['remove_duplicates']) ? $options['remove_duplicates'] : null); + $this->appendOptionalBool($fields, 'remove_duplicate', isset($options['remove_duplicate']) ? $options['remove_duplicate'] : null); + if (isset($options['email_address_column'])) { + $fields['email_address_column'] = $options['email_address_column']; + } + if (!empty($options['return_url'])) { + $fields['return_url'] = $options['return_url']; + } + if (isset($options['first_name_column']) && $this->isProvided($options['first_name_column'])) { + $fields['first_name_column'] = $options['first_name_column']; + } + if (isset($options['last_name_column']) && $this->isProvided($options['last_name_column'])) { + $fields['last_name_column'] = $options['last_name_column']; + } + if (isset($options['gender_column']) && $this->isProvided($options['gender_column'])) { + $fields['gender_column'] = $options['gender_column']; + } + if (isset($options['ip_address_column']) && $this->isProvided($options['ip_address_column'])) { + $fields['ip_address_column'] = $options['ip_address_column']; + } + if (isset($options['allow_phase_2'])) { + $fields['allow_phase_2'] = $options['allow_phase_2'] ? 'true' : 'false'; + } + return $fields; + } + + private function postBulkRootForm($path, array $fields) + { + $this->checkValidApiKey(); + $rsp = new ZBSendFileResponse(); + $this->formPost(self::BulkApiRootUrl . $path, $fields, $rsp); + return $rsp; + } + + private function appendOptionalBool(array &$fields, $key, $value) + { + if ($value !== null) { + $fields[$key] = $value ? 'true' : 'false'; + } + } + private function buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow) { $fields = array('domain_column' => $domainColumn); diff --git a/test/OfflineApiTest.php b/test/OfflineApiTest.php index 72408b1..cb95ce8 100644 --- a/test/OfflineApiTest.php +++ b/test/OfflineApiTest.php @@ -470,6 +470,54 @@ public function testPriority3EndpointsAndZeroColumn() $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() @@ -495,6 +543,15 @@ function () use ($client) { 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); }, @@ -535,6 +592,9 @@ function () use ($client) { $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'); diff --git a/test/OfflineHttp.php b/test/OfflineHttp.php index 06b7088..1676f96 100644 --- a/test/OfflineHttp.php +++ b/test/OfflineHttp.php @@ -153,6 +153,9 @@ private function answer(array $request) 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":[]}'); } diff --git a/test/ZeroBounceTest.php b/test/ZeroBounceTest.php index ffcecd8..6564b53 100644 --- a/test/ZeroBounceTest.php +++ b/test/ZeroBounceTest.php @@ -737,4 +737,26 @@ public function testSendEmailFinderFileUsesBulkEmailFinderEndpoint() } } + public function testValidateS3FilePostsToBulkRoot() + { + ZeroBounce::Instance()->responseText = '{ + "success": true, + "file_id": "s3-job", + "message": "File Accepted" + }'; + ZeroBounce::Instance()->validateS3File(array('file_name' => 'bucket/key.csv')); + $this->assertStringContainsString('bulkapi.zerobounce.net/validateS3file', ZeroBounce::Instance()->lastFormPostUrl); + } + + public function testListS3FilesCallsBulkListS3files() + { + ZeroBounce::Instance()->responseText = '[{"file_name":"bucket/key.csv","size":1024}]'; + $result = ZeroBounce::Instance()->listS3Files('my-bucket', 'key'); + $this->assertStringContainsString('bulkapi.zerobounce.net/listS3files', ZeroBounce::Instance()->lastRequestUrl); + $this->assertStringContainsString('bucket=my-bucket', ZeroBounce::Instance()->lastRequestUrl); + $this->assertStringContainsString('search=key', ZeroBounce::Instance()->lastRequestUrl); + $this->assertIsArray($result); + $this->assertEquals('bucket/key.csv', $result[0]['file_name']); + } + } From 226965541164222fbc6acc2f1ea5baca53377d44 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:53:09 +0400 Subject: [PATCH 10/20] feat: S3 email finder / domain search and SFTP/FTP bulk endpoints Ports b0444f8 from the Python SDK. These are documented but had never been in this SDK: - POST /emailFinderS3file and /domainSearchS3file, from https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api - POST /validateSFTPfile, /scoreSFTPfile, /emailFinderSFTPfile, /domainSearchSFTPfile and /exportSFTPfile, from https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api All six file jobs share bulkRootFileJob, which sends has_header_row and remove_duplicates as the strings true/false and leaves remove_duplicates and return_url out until set. Two shapes differ from the S3 ones and are easy to get wrong: - validateSFTPFile's file_name is optional; omitting it uses the input bucket from the account's settings. Every other job requires it. - exportSFTPFile takes export_path, not the export_bucket that exportS3File uses, and omitting it uses the export path from those settings. Tests: 66 pass, 1 pre-existing skip. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +- README.md | 26 ++++++++ documentation.md | 26 ++++++++ src/ZeroBouncePriority3.php | 130 +++++++++++++++++++++++++++++++++++- test/OfflineApiTest.php | 86 ++++++++++++++++++++++++ test/OfflineHttp.php | 3 + 6 files changed, 272 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dee1891..4968b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). +- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3Files`, `emailFinderS3File`, `domainSearchS3File`). +- SFTP/FTP bulk (`validateSFTPFile`, `scoreSFTPFile`, `emailFinderSFTPFile`, `domainSearchSFTPFile`, `exportSFTPFile`). The server, path and protocol come from your ZeroBounce settings. `validateSFTPFile`'s `file_name` is optional; `exportSFTPFile` takes `export_path` rather than `export_bucket`. - `validate` accepts `domainInfo`, `greylistProcessing`, and `callbackUrl` on `ZBValidateOptions`. `callbackUrl` needs `greylistProcessing = true` and is rejected without it, and when blank, before a request is sent. - `ZBValidateResponse` carries `domainWebsiteExists`, `domainRegistrantCompanyName`, and `message`. `message` is read from the API's `Message` key, returned while greylist processing is still running. - Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `getInboxPlacementProviders`, `getInboxPlacementTestTypes`, `createInboxPlacementTest`, `getInboxPlacementTest`, `listInboxPlacementTests`, `deleteInboxPlacementTest`, `getInboxPlacementWebhook`, `setInboxPlacementWebhook`. diff --git a/README.md b/README.md index 0dd272f..8a8f84b 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,32 @@ echo $response->message; echo $response->domainWebsiteExists; ``` +- S3 and SFTP/FTP bulk jobs: +```php +// Amazon S3: https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api +ZeroBounce::Instance()->emailFinderS3File(array( + 'file_name' => 'lists/leads.csv', + 'has_header_row' => true, +)); +ZeroBounce::Instance()->domainSearchS3File(array( + 'file_name' => 'lists/leads.csv', + 'has_header_row' => true, + 'remove_duplicates' => true, + 'return_url' => 'https://example.com/zerobounce-done', +)); + +// SFTP/FTP: https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api +// The server, path and protocol come from your ZeroBounce settings. +// file_name is optional for validateSFTPFile only. +ZeroBounce::Instance()->validateSFTPFile(array('has_header_row' => true)); +ZeroBounce::Instance()->scoreSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); +ZeroBounce::Instance()->emailFinderSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); +ZeroBounce::Instance()->domainSearchSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); + +// exportSFTPFile takes export_path, not export_bucket. Omit it to use your settings. +ZeroBounce::Instance()->exportSFTPFile('', '/exports'); +``` + - Inbox Placement Tester: ```php // Lives on https://bulkapi.zerobounce.net/api/ipt and spends inbox test credits. diff --git a/documentation.md b/documentation.md index 29a7255..6924496 100644 --- a/documentation.md +++ b/documentation.md @@ -73,6 +73,32 @@ echo $response->message; echo $response->domainWebsiteExists; ``` +- S3 and SFTP/FTP bulk jobs: +```php +// Amazon S3: https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api +ZeroBounce::Instance()->emailFinderS3File(array( + 'file_name' => 'lists/leads.csv', + 'has_header_row' => true, +)); +ZeroBounce::Instance()->domainSearchS3File(array( + 'file_name' => 'lists/leads.csv', + 'has_header_row' => true, + 'remove_duplicates' => true, + 'return_url' => 'https://example.com/zerobounce-done', +)); + +// SFTP/FTP: https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api +// The server, path and protocol come from your ZeroBounce settings. +// file_name is optional for validateSFTPFile only. +ZeroBounce::Instance()->validateSFTPFile(array('has_header_row' => true)); +ZeroBounce::Instance()->scoreSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); +ZeroBounce::Instance()->emailFinderSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); +ZeroBounce::Instance()->domainSearchSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); + +// exportSFTPFile takes export_path, not export_bucket. Omit it to use your settings. +ZeroBounce::Instance()->exportSFTPFile('', '/exports'); +``` + - Inbox Placement Tester: ```php // Lives on https://bulkapi.zerobounce.net/api/ipt and spends inbox test credits. diff --git a/src/ZeroBouncePriority3.php b/src/ZeroBouncePriority3.php index 26a44ec..2e62285 100644 --- a/src/ZeroBouncePriority3.php +++ b/src/ZeroBouncePriority3.php @@ -5,7 +5,7 @@ use Exception; /** - * Priority 3 API: bulk email finder, domain search, list evaluator, filters, S3 helpers. + * Priority 3 API: bulk email finder, domain search, list evaluator, filters, S3 and SFTP/FTP bulk. */ trait ZeroBouncePriority3 { @@ -272,6 +272,134 @@ private function appendOptionalBool(array &$fields, $key, $value) } } + /** + * Shared shape for the documented S3 and SFTP file jobs on the bulkapi root. + * Only fields the caller set are sent; booleans go as the strings true/false. + * + * @param bool $fileNameRequired false for validateSFTPfile, whose file_name is optional + * @return ZBSendFileResponse + * @throws ZBException + */ + private function bulkRootFileJob($path, array $options, $fileNameRequired = true) + { + $fileName = isset($options['file_name']) ? $options['file_name'] : null; + if ($fileNameRequired && ($fileName === null || trim((string)$fileName) === '')) { + throw new ZBMissingParameterException('file_name is required'); + } + if ($fileName !== null && trim((string)$fileName) === '') { + throw new ZBMissingParameterException('file_name is required'); + } + if (!isset($options['has_header_row'])) { + throw new ZBMissingParameterException('has_header_row is required'); + } + + $fields = array(); + if ($fileName !== null) { + $fields['file_name'] = $fileName; + } + $this->appendOptionalBool($fields, 'has_header_row', $options['has_header_row']); + if (isset($options['remove_duplicates'])) { + $this->appendOptionalBool($fields, 'remove_duplicates', $options['remove_duplicates']); + } + if (isset($options['return_url'])) { + $fields['return_url'] = $options['return_url']; + } + return $this->postBulkRootForm($path, $fields); + } + + /** + * Run the email finder on a file in your Amazon S3 bucket. POST /emailFinderS3file + * + * @return ZBSendFileResponse + * @throws ZBException + */ + public function emailFinderS3File(array $options) + { + return $this->bulkRootFileJob('/emailFinderS3file', $options); + } + + /** + * Run domain search on a file in your Amazon S3 bucket. POST /domainSearchS3file + * + * @return ZBSendFileResponse + * @throws ZBException + */ + public function domainSearchS3File(array $options) + { + return $this->bulkRootFileJob('/domainSearchS3file', $options); + } + + // SFTP / FTP bulk. The server, path and protocol come from your ZeroBounce settings. + + /** + * Validate a file from your SFTP/FTP server. POST /validateSFTPfile + * + * The docs list file_name as optional here; leave it out to use the input + * bucket configured in your settings. + * + * @return ZBSendFileResponse + * @throws ZBException + */ + public function validateSFTPFile(array $options) + { + return $this->bulkRootFileJob('/validateSFTPfile', $options, false); + } + + /** + * Score a file from your SFTP/FTP server. POST /scoreSFTPfile + * + * @return ZBSendFileResponse + * @throws ZBException + */ + public function scoreSFTPFile(array $options) + { + return $this->bulkRootFileJob('/scoreSFTPfile', $options); + } + + /** + * Run the email finder on a file from your SFTP/FTP server. POST /emailFinderSFTPfile + * + * @return ZBSendFileResponse + * @throws ZBException + */ + public function emailFinderSFTPFile(array $options) + { + return $this->bulkRootFileJob('/emailFinderSFTPfile', $options); + } + + /** + * Run domain search on a file from your SFTP/FTP server. POST /domainSearchSFTPfile + * + * @return ZBSendFileResponse + * @throws ZBException + */ + public function domainSearchSFTPFile(array $options) + { + return $this->bulkRootFileJob('/domainSearchSFTPfile', $options); + } + + /** + * Export a validated or scored file back to your SFTP/FTP server. POST /exportSFTPfile + * + * Without $exportPath the export path from your ZeroBounce settings is used. + * Note this takes export_path, not the export_bucket that exportS3File uses. + * + * @return ZBSendFileResponse + * @throws ZBException + */ + public function exportSFTPFile($fileId, $exportPath = null) + { + $this->requireProvided($fileId, 'fileId is required'); + if ($exportPath !== null && trim((string)$exportPath) === '') { + throw new ZBMissingParameterException('exportPath is required'); + } + $fields = array('file_id' => $fileId); + if ($exportPath !== null) { + $fields['export_path'] = $exportPath; + } + return $this->postBulkRootForm('/exportSFTPfile', $fields); + } + private function buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow) { $fields = array('domain_column' => $domainColumn); diff --git a/test/OfflineApiTest.php b/test/OfflineApiTest.php index cb95ce8..718b951 100644 --- a/test/OfflineApiTest.php +++ b/test/OfflineApiTest.php @@ -1094,6 +1094,92 @@ public function testListEvaluatorUsesItsOwnBaseUrlAndEncodesFileId() $this->assertSame('/custom/listevaluator/fid/', $custom['path']); } + + public function testS3AndSftpJobsSendDocumentedFieldsOnly() + { + $api = new FakeApi(); + $client = $this->client($api, 'bulk-key'); + + $client->emailFinderS3File(array('file_name' => 'inbox.csv', 'has_header_row' => false)); + $finder = $this->only($api); + $this->assertSame('POST', $finder['method']); + $this->assertSame('bulkapi.zerobounce.net', $finder['host']); + $this->assertSame('/emailFinderS3file', $finder['path']); + $this->assertSame('inbox.csv', $finder['fields']['file_name']); + $this->assertSame('false', $finder['fields']['has_header_row']); + $this->assertArrayNotHasKey('remove_duplicates', $finder['fields']); + $this->assertArrayNotHasKey('return_url', $finder['fields']); + + $api->clear(); + $client->domainSearchS3File(array( + 'file_name' => 'inbox.csv', + 'has_header_row' => true, + 'remove_duplicates' => false, + 'return_url' => 'https://example.com/done', + )); + $search = $this->only($api); + $this->assertSame('/domainSearchS3file', $search['path']); + $this->assertSame('true', $search['fields']['has_header_row']); + $this->assertSame('false', $search['fields']['remove_duplicates']); + $this->assertSame('https://example.com/done', $search['fields']['return_url']); + + // validateSFTPfile is the one job whose file_name the docs mark optional. + $api->clear(); + $client->validateSFTPFile(array('has_header_row' => true)); + $sftp = $this->only($api); + $this->assertSame('/validateSFTPfile', $sftp['path']); + $this->assertArrayNotHasKey('file_name', $sftp['fields']); + + $api->clear(); + $client->scoreSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); + $this->assertSame('/scoreSFTPfile', $this->only($api)['path']); + + $api->clear(); + $client->emailFinderSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); + $this->assertSame('/emailFinderSFTPfile', $this->only($api)['path']); + + $api->clear(); + $client->domainSearchSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); + $this->assertSame('/domainSearchSFTPfile', $this->only($api)['path']); + + // exportSFTPfile takes export_path, not the export_bucket exportS3File uses. + $api->clear(); + $client->exportSFTPFile('fid'); + $exported = $this->only($api); + $this->assertSame('/exportSFTPfile', $exported['path']); + $this->assertSame('fid', $exported['fields']['file_id']); + $this->assertArrayNotHasKey('export_path', $exported['fields']); + + $api->clear(); + $client->exportSFTPFile('fid', '/exports'); + $this->assertSame('/exports', $this->only($api)['fields']['export_path']); + } + + public function testS3AndSftpJobsRejectInvalidArgumentsBeforeRequest() + { + $api = new FakeApi(); + $client = $this->client($api, 'bulk-key'); + + $cases = array( + function () use ($client) { $client->emailFinderS3File(array('has_header_row' => true)); }, + function () use ($client) { $client->domainSearchS3File(array('file_name' => 'f.csv')); }, + function () use ($client) { $client->scoreSFTPFile(array('file_name' => ' ', 'has_header_row' => true)); }, + function () use ($client) { $client->domainSearchSFTPFile(array('file_name' => 'f.csv')); }, + function () use ($client) { $client->exportSFTPFile(''); }, + function () use ($client) { $client->exportSFTPFile('fid', ' '); }, + ); + foreach ($cases as $case) { + $threw = false; + try { + $case(); + } catch (ZBException $e) { + $threw = true; + } + $this->assertTrue($threw); + } + $this->assertSame(0, $api->count()); + } + private function client(FakeApi $api, $key) { $client = new OfflineClient($api); diff --git a/test/OfflineHttp.php b/test/OfflineHttp.php index 1676f96..b7827aa 100644 --- a/test/OfflineHttp.php +++ b/test/OfflineHttp.php @@ -153,6 +153,9 @@ private function answer(array $request) if (strpos($path, 'getfile') !== false) { return $this->respond(200, 'text/csv', "email\n" . $key . "@example.com\n"); } + if (preg_match('#/(emailFinder|domainSearch|validate|score|export)(S3|SFTP)file$#', $path)) { + return $this->respond(200, 'application/json', '{"success":true,"file_id":' . $quoted . '}'); + } if (strpos($path, 'listS3files') !== false) { return $this->respond(200, 'application/json', '[{"file_name":' . $quoted . ',"size":1}]'); } From fc7df561689ab2bd5dcb7c9e88f59b64b77f082d Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:46:03 +0400 Subject: [PATCH 11/20] feat: add ai_agent_mailbox sub_status (RD-66) The API now returns ai_agent_mailbox under both the valid and catch-all statuses. No new status was added. Adds ZBValidateSubStatus::AiAgentMailbox following RoleBasedCatchAll, so callers have a named constant to compare against. On unknown values: ZBValidateResponse::getValue() branches on $classKey === "sub_status", but ZBResponse::deserialize() camel-cases the key to "subStatus" before calling it, so that branch never runs and the raw value is assigned unchanged. Deliberately left alone: pass-through is the safer outcome, because activating the branch would blank out any sub_status the installed SDK predates -- including ai_agent_mailbox for anyone on an older version. A test pins the current behaviour so it cannot regress silently. ("status" has no underscore, so its own normalisation does run.) No sub_status_ai_agent_mailbox usage counter: it is not documented. Co-Authored-By: Claude Opus 5 --- src/ZBValidateSubStatus.php | 1 + test/ZeroBounceTest.php | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/ZBValidateSubStatus.php b/src/ZBValidateSubStatus.php index 4844365..c24ac45 100644 --- a/src/ZBValidateSubStatus.php +++ b/src/ZBValidateSubStatus.php @@ -31,4 +31,5 @@ class ZBValidateSubStatus extends BasicEnum const Disposable = "disposable"; const Toxic = "toxic"; const AcceptAll = "accept_all"; + const AiAgentMailbox = "ai_agent_mailbox"; } \ No newline at end of file diff --git a/test/ZeroBounceTest.php b/test/ZeroBounceTest.php index 6564b53..a50b76b 100644 --- a/test/ZeroBounceTest.php +++ b/test/ZeroBounceTest.php @@ -6,6 +6,7 @@ require_once('MockZeroBounce.php'); use ZeroBounce\SDK\ZBValidateStatus; +use ZeroBounce\SDK\ZBValidateSubStatus; use ZeroBounce\SDK\ZBDownloadType; use ZeroBounce\SDK\ZBGetFileOptions; use ZeroBounce\SDK\ZeroBounce as SdkZeroBounce; @@ -214,6 +215,48 @@ public function testValidate() $this->assertEquals($response->status, ZBValidateStatus::Valid); } + public function testValidateAiAgentMailboxValid() + { + ZeroBounce::Instance()->responseText = "{ + \"address\": \"agent@example.com\", + \"status\": \"valid\", + \"sub_status\": \"ai_agent_mailbox\" + }"; + $response = ZeroBounce::Instance()->validate("agent@example.com"); + $this->assertEquals($response->status, ZBValidateStatus::Valid); + $this->assertEquals($response->subStatus, ZBValidateSubStatus::AiAgentMailbox); + } + + public function testValidateAiAgentMailboxCatchAll() + { + ZeroBounce::Instance()->responseText = "{ + \"address\": \"agent@example.com\", + \"status\": \"catch-all\", + \"sub_status\": \"ai_agent_mailbox\" + }"; + $response = ZeroBounce::Instance()->validate("agent@example.com"); + $this->assertEquals($response->subStatus, ZBValidateSubStatus::AiAgentMailbox); + } + + public function testValidateUnknownSubStatusIsPassedThrough() + { + // ZBValidateResponse::getValue() branches on $classKey === "sub_status", + // but ZBResponse::deserialize() has already camel-cased the key to + // "subStatus" by then, so that branch never runs and the raw value is + // assigned as-is. That is the safe outcome -- a sub_status the SDK has + // not heard of yet reaches the caller instead of being blanked -- so it + // is pinned here rather than "fixed". ("status" has no underscore, so + // its normalisation does run.) + ZeroBounce::Instance()->responseText = "{ + \"address\": \"future@example.com\", + \"status\": \"valid\", + \"sub_status\": \"some_future_sub_status\" + }"; + $response = ZeroBounce::Instance()->validate("future@example.com"); + $this->assertEquals(ZBValidateStatus::Valid, $response->status); + $this->assertEquals("some_future_sub_status", $response->subStatus); + } + public function testGetCredits() { ZeroBounce::Instance()->responseText = "{ From 880fffc353a5ffc91b3ad74f720de09a98a2ae72 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:17:39 +0400 Subject: [PATCH 12/20] fix: match what the live API actually accepts Verified against the live API; where the API and the docs disagree, the API wins and the call site says so. - validatebatch takes timeout as a JSON string. Sent as a number the API rejects the whole batch with the account-level "Invalid API Key or your account ran out of credits". - The Inbox Placement Tester authenticates with an Authorization: Bearer header, not an api_key parameter. - send-evaluated-file takes the list evaluator's file_id as a form field, not a file upload. - The S3 and SFTP bulk jobs require return_url and file_name; without them the API answers "This field is required". Co-Authored-By: Claude Opus 5 --- src/ZeroBounce.php | 96 ++++++++++---------------------- src/ZeroBounceInboxPlacement.php | 10 +++- src/ZeroBouncePriority3.php | 77 +++++++++++++++++-------- test/OfflineApiTest.php | 72 ++++++++++++++++++------ test/OfflineHttp.php | 21 +++++++ 5 files changed, 164 insertions(+), 112 deletions(-) diff --git a/src/ZeroBounce.php b/src/ZeroBounce.php index e99e175..5f4d662 100644 --- a/src/ZeroBounce.php +++ b/src/ZeroBounce.php @@ -199,7 +199,11 @@ function ($email) { $params = ['email_batch' => $params]; if ($options instanceof ZBValidateOptions) { if ($options->timeout !== null) { - $params['timeout'] = $options->timeout; + // LIVE: the API requires timeout as a JSON string ("30"). As a + // number it rejects the whole batch with the account-level + // "Invalid API Key or your account ran out of credits" error. + // activity_data and verify_plus stay JSON booleans. + $params['timeout'] = (string)$options->timeout; } if ($options->activityData !== null) { $params['activity_data'] = $options->activityData; @@ -862,40 +866,28 @@ public function _deleteFile($scoring, $fileId) */ protected function executeHttp($method, $url, array $headers = array(), $body = null, $timeout = 120) { - $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); - } - $context = stream_context_create(array('http' => $contextOptions)); - $responseBody = @file_get_contents($url, false, $context); - if ($responseBody === false) { - $responseBody = ''; - } - $responseHeaders = $this->lastHttpHeaders(); - return array( - 'status' => (int)$this->getHttpCode($responseHeaders), - 'headers' => is_array($responseHeaders) ? $responseHeaders : array(), - 'body' => $responseBody, - ); - } - + // Every verb goes through curl. The http:// stream wrapper reports its + // status only through PHP's predefined response-header variable, which + // the engine exposes to the calling function alone and 8.5 deprecates; + // read from anywhere else it is always empty, so an error status was + // silently recorded as 0 and a 404 looked like a successful response. + $responseHeaders = array(); $curl = curl_init(); $curlOpts = array( CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => true, + CURLOPT_HEADER => false, CURLOPT_TIMEOUT => $timeout, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, - CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_CUSTOMREQUEST => strtoupper($method), + CURLOPT_HEADERFUNCTION => function ($handle, $header) use (&$responseHeaders) { + $line = trim($header); + if ($line !== '') { + $responseHeaders[] = $line; + } + return strlen($header); + }, ); if ($headers) { $curlOpts[CURLOPT_HTTPHEADER] = $headers; @@ -906,44 +898,17 @@ protected function executeHttp($method, $url, array $headers = array(), $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' => $responseHeaders, 'body' => ''); } return array( 'status' => $status, - 'headers' => $headerLines, - 'body' => $responseBody === false ? '' : $responseBody, + 'headers' => $responseHeaders, + 'body' => (string)$raw, ); } - /** - * @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; - } - 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 { @@ -951,6 +916,10 @@ protected function request($url, $response) $json = $this->readJsonBody($result); $response->Deserialize($json); return (int)$result['status']; + } catch (ZBApiException $e) { + // Rebuilt rather than rethrown so the message is redacted, but the + // HTTP status survives for callers that switch on it. + throw new ZBApiException($this->redactApiKey($e->getMessage()), $e->getStatusCode()); } catch (Exception $e) { throw new ZBException($this->redactApiKey($e->getMessage())); } @@ -1023,6 +992,7 @@ private function readJsonBody(array $result) throw new ZBException('ZeroBounce returned a response that is not valid JSON.'); } ZBHttpError::throwIfBareAccountError($decoded); + ZBHttpError::throwIfSuccessIsFalse($decoded, $status, $body, $headers); return $body; } @@ -1124,16 +1094,6 @@ private function checkValidApiKey() if (!$this->apiKey) throw new ZBMissingApiKeyException("ZeroBounce SDK is not initialized. Please call ZeroBounce::Instance()->initialize(\"API_KEY\") first"); } - private function getHttpCode($httpResponseHeader) - { - if (is_array($httpResponseHeader) && !empty($httpResponseHeader)) { - $parts = explode(' ', $httpResponseHeader[0]); - if (count($parts) > 1) //HTTP/1.0 - return intval($parts[1]); //Get code - } - return 0; - } - private function build_data_files($boundary, $fields, $files) { $data = ''; diff --git a/src/ZeroBounceInboxPlacement.php b/src/ZeroBounceInboxPlacement.php index cc44540..5093750 100644 --- a/src/ZeroBounceInboxPlacement.php +++ b/src/ZeroBounceInboxPlacement.php @@ -225,10 +225,14 @@ private function iptTestPath($testId) private function iptRequest($method, $path, array $query = array(), $body = null) { $this->checkValidApiKey(); - $query = array_merge(array('api_key' => $this->apiKey), $query); - $url = $this->inboxPlacementBaseUrl . $path . '?' . http_build_query($query); + $url = $this->inboxPlacementBaseUrl . $path; + if (!empty($query)) { + $url .= '?' . http_build_query($query); + } - $headers = array(); + // The documented auth for this API is a Bearer header, not an api_key + // query parameter. + $headers = array('Authorization: Bearer ' . $this->apiKey); $content = null; if ($body !== null) { $headers[] = 'Content-Type: application/json'; diff --git a/src/ZeroBouncePriority3.php b/src/ZeroBouncePriority3.php index 2e62285..81d7d3b 100644 --- a/src/ZeroBouncePriority3.php +++ b/src/ZeroBouncePriority3.php @@ -119,14 +119,28 @@ public function getListEvaluatorResult($fileId) return $holder->data; } - public function sendEvaluatedFile($filepath) - { - return $this->sendBulkRootMultipartFile(self::BulkApiBaseUrl . '/send-evaluated-file/', $filepath, array()); - } - - public function sendEvaluatedFileStream($streamContent, $fileName) + /** + * Submit a finished evaluation for validation. POST /v2/send-evaluated-file/ + * + * LIVE: this takes the list evaluator's file_id as a form field, not a file + * upload as the docs describe — an upload is rejected with + * "file_id: This field is required". It returns a new validation job's + * file_id, which answers "File cannot be found" for a short while after it + * is accepted. + * + * @param string $fileId the file_id returned by the list evaluator + * @return ZBSendFileResponse + * @throws ZBException + */ + public function sendEvaluatedFile($fileId) { - return $this->sendBulkRootMultipartContent(self::BulkApiBaseUrl . '/send-evaluated-file/', $fileName, $streamContent, array()); + if ($fileId === null || trim((string)$fileId) === '') { + throw new ZBMissingParameterException('file_id is required'); + } + return $this->postBulkRootFormAt( + self::BulkApiBaseUrl . '/send-evaluated-file/', + array('file_id' => (string)$fileId) + ); } /** @@ -258,10 +272,24 @@ private function buildValidateS3Fields(array $options) } private function postBulkRootForm($path, array $fields) + { + return $this->postBulkRootFormAt(self::BulkApiRootUrl . $path, $fields); + } + + /** + * Post a form to a fully built URL, for the endpoints that are not on the + * bulkapi root (send-evaluated-file is on the /v2 base). + * + * @param string $url + * @param array $fields + * @return ZBSendFileResponse + * @throws ZBException + */ + private function postBulkRootFormAt($url, array $fields) { $this->checkValidApiKey(); $rsp = new ZBSendFileResponse(); - $this->formPost(self::BulkApiRootUrl . $path, $fields, $rsp); + $this->formPost($url, $fields, $rsp); return $rsp; } @@ -274,36 +302,39 @@ private function appendOptionalBool(array &$fields, $key, $value) /** * Shared shape for the documented S3 and SFTP file jobs on the bulkapi root. - * Only fields the caller set are sent; booleans go as the strings true/false. + * Booleans go on the wire as the strings true/false. + * + * LIVE: file_name and return_url are required by every job, including + * validateSFTPfile, although the docs mark them optional. Without them the + * API answers "file_name: This field is required" and "Provided return URL + * is not permitted: None". Both are checked here, so no request goes out + * that the API would reject. remove_duplicates stays optional. * - * @param bool $fileNameRequired false for validateSFTPfile, whose file_name is optional * @return ZBSendFileResponse * @throws ZBException */ - private function bulkRootFileJob($path, array $options, $fileNameRequired = true) + private function bulkRootFileJob($path, array $options) { $fileName = isset($options['file_name']) ? $options['file_name'] : null; - if ($fileNameRequired && ($fileName === null || trim((string)$fileName) === '')) { - throw new ZBMissingParameterException('file_name is required'); - } - if ($fileName !== null && trim((string)$fileName) === '') { + if ($fileName === null || trim((string)$fileName) === '') { throw new ZBMissingParameterException('file_name is required'); } if (!isset($options['has_header_row'])) { throw new ZBMissingParameterException('has_header_row is required'); } - - $fields = array(); - if ($fileName !== null) { - $fields['file_name'] = $fileName; + $returnUrl = isset($options['return_url']) ? $options['return_url'] : null; + if ($returnUrl === null || trim((string)$returnUrl) === '') { + throw new ZBMissingParameterException( + 'return_url is required: the API rejects a job without it' + ); } + + $fields = array('file_name' => $fileName); $this->appendOptionalBool($fields, 'has_header_row', $options['has_header_row']); if (isset($options['remove_duplicates'])) { $this->appendOptionalBool($fields, 'remove_duplicates', $options['remove_duplicates']); } - if (isset($options['return_url'])) { - $fields['return_url'] = $options['return_url']; - } + $fields['return_url'] = $returnUrl; return $this->postBulkRootForm($path, $fields); } @@ -342,7 +373,7 @@ public function domainSearchS3File(array $options) */ public function validateSFTPFile(array $options) { - return $this->bulkRootFileJob('/validateSFTPfile', $options, false); + return $this->bulkRootFileJob('/validateSFTPfile', $options); } /** diff --git a/test/OfflineApiTest.php b/test/OfflineApiTest.php index 718b951..72ea6b1 100644 --- a/test/OfflineApiTest.php +++ b/test/OfflineApiTest.php @@ -5,6 +5,7 @@ use DateTime; use PHPUnit\Framework\TestCase; use ReflectionClass; +use ZeroBounce\SDK\ZBApiException; use ZeroBounce\SDK\ZBBaseUrl; use ZeroBounce\SDK\ZBDownloadType; use ZeroBounce\SDK\ZBException; @@ -146,7 +147,8 @@ public function testValidateBatchSendsExplicitOptions() $options->verifyPlus = true; $client->validateBatch(array('a@example.com'), $options); $request = $this->only($api); - $this->assertStringContainsString('"timeout":30', $request['fields']['__json']); + // LIVE: the API requires timeout as a JSON string. + $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']); @@ -436,13 +438,11 @@ public function testPriority3EndpointsAndZeroColumn() $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'); + // LIVE: this takes the evaluator's file_id as a form field, not an upload. + $client->sendEvaluatedFile('eval-file-1'); $evaluated = $this->only($api); $this->assertSame('/v2/send-evaluated-file/', $evaluated['path']); - $this->assertSame('evaluated.csv', $evaluated['fields']['__filename']); + $this->assertSame('eval-file-1', $evaluated['fields']['file_id']); $api->clear(); $added = $client->addFilter('exclude', 'domain', '0'); @@ -964,7 +964,9 @@ public function testInboxPlacementUsesItsOwnHostAndCamelCaseJson() $this->assertSame('GET', $providers['method']); $this->assertSame('bulkapi.zerobounce.net', $providers['host']); $this->assertSame('/api/ipt/providers', $providers['path']); - $this->assertSame('ipt-key', $providers['fields']['api_key']); + // The documented auth for this API is a Bearer header, not a query key. + $this->assertSame('Bearer ipt-key', $providers['authorization']); + $this->assertArrayNotHasKey('api_key', $providers['fields']); $api->clear(); $client->getInboxPlacementTestTypes(); @@ -1018,7 +1020,8 @@ public function testInboxPlacementListSendsOnlyTheFiltersThatAreSet() $client = $this->client($api, 'ipt-key'); $client->listInboxPlacementTests(); - $this->assertSame(array('api_key'), array_keys($this->only($api)['fields'])); + // The key travels in the Bearer header, so an unfiltered list sends no query. + $this->assertSame(array(), array_keys($this->only($api)['fields'])); $api->clear(); $client->listInboxPlacementTests('finished', '2026-01-01', '2026-02-01', 2, 50); @@ -1100,15 +1103,16 @@ public function testS3AndSftpJobsSendDocumentedFieldsOnly() $api = new FakeApi(); $client = $this->client($api, 'bulk-key'); - $client->emailFinderS3File(array('file_name' => 'inbox.csv', 'has_header_row' => false)); + $client->emailFinderS3File(array('file_name' => 'inbox.csv', 'has_header_row' => false, 'return_url' => 'https://example.com/done')); $finder = $this->only($api); $this->assertSame('POST', $finder['method']); $this->assertSame('bulkapi.zerobounce.net', $finder['host']); $this->assertSame('/emailFinderS3file', $finder['path']); $this->assertSame('inbox.csv', $finder['fields']['file_name']); $this->assertSame('false', $finder['fields']['has_header_row']); + // LIVE: return_url is required; remove_duplicates stays optional. + $this->assertSame('https://example.com/done', $finder['fields']['return_url']); $this->assertArrayNotHasKey('remove_duplicates', $finder['fields']); - $this->assertArrayNotHasKey('return_url', $finder['fields']); $api->clear(); $client->domainSearchS3File(array( @@ -1123,23 +1127,41 @@ public function testS3AndSftpJobsSendDocumentedFieldsOnly() $this->assertSame('false', $search['fields']['remove_duplicates']); $this->assertSame('https://example.com/done', $search['fields']['return_url']); - // validateSFTPfile is the one job whose file_name the docs mark optional. + // LIVE: validateSFTPfile needs file_name too, although the docs mark it + // optional ("file_name: This field is required"). $api->clear(); - $client->validateSFTPFile(array('has_header_row' => true)); + $client->validateSFTPFile(array( + 'file_name' => 'my_file.txt', + 'has_header_row' => true, + 'return_url' => 'https://example.com/done', + )); $sftp = $this->only($api); $this->assertSame('/validateSFTPfile', $sftp['path']); - $this->assertArrayNotHasKey('file_name', $sftp['fields']); + $this->assertSame('my_file.txt', $sftp['fields']['file_name']); + + // A job without file_name or return_url never reaches the API. + foreach (array( + array('has_header_row' => true, 'return_url' => 'https://example.com/done'), + array('file_name' => 'f.csv', 'has_header_row' => true), + ) as $incomplete) { + try { + $client->validateSFTPFile($incomplete); + $this->fail('expected a rejection'); + } catch (ZBMissingParameterException $e) { + $this->assertNotSame('', $e->getMessage()); + } + } $api->clear(); - $client->scoreSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); + $client->scoreSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true, 'return_url' => 'https://example.com/done')); $this->assertSame('/scoreSFTPfile', $this->only($api)['path']); $api->clear(); - $client->emailFinderSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); + $client->emailFinderSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true, 'return_url' => 'https://example.com/done')); $this->assertSame('/emailFinderSFTPfile', $this->only($api)['path']); $api->clear(); - $client->domainSearchSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true)); + $client->domainSearchSFTPFile(array('file_name' => 'my_file.txt', 'has_header_row' => true, 'return_url' => 'https://example.com/done')); $this->assertSame('/domainSearchSFTPfile', $this->only($api)['path']); // exportSFTPfile takes export_path, not the export_bucket exportS3File uses. @@ -1161,9 +1183,9 @@ public function testS3AndSftpJobsRejectInvalidArgumentsBeforeRequest() $client = $this->client($api, 'bulk-key'); $cases = array( - function () use ($client) { $client->emailFinderS3File(array('has_header_row' => true)); }, + function () use ($client) { $client->emailFinderS3File(array('has_header_row' => true, 'return_url' => 'https://example.com/done')); }, function () use ($client) { $client->domainSearchS3File(array('file_name' => 'f.csv')); }, - function () use ($client) { $client->scoreSFTPFile(array('file_name' => ' ', 'has_header_row' => true)); }, + function () use ($client) { $client->scoreSFTPFile(array('file_name' => ' ', 'has_header_row' => true, 'return_url' => 'https://example.com/done')); }, function () use ($client) { $client->domainSearchSFTPFile(array('file_name' => 'f.csv')); }, function () use ($client) { $client->exportSFTPFile(''); }, function () use ($client) { $client->exportSFTPFile('fid', ' '); }, @@ -1180,6 +1202,20 @@ function () use ($client) { $client->exportSFTPFile('fid', ' '); }, $this->assertSame(0, $api->count()); } + public function testSuccessFalseOnAnHttp200IsAnError() + { + // LIVE: the API answers an unknown file id, and an unknown + // list-evaluator id, with an HTTP 200 whose body says success: false. + $api = new FakeApi(); + $client = $this->client($api, 'success-false-key'); + $error = $this->capture(function () use ($client) { + $client->fileStatus('0d1b7a4e-1111-2222-3333-444455556666'); + }); + $this->assertSame('File cannot be found.', $error->getMessage()); + $this->assertInstanceOf(ZBApiException::class, $error); + $this->assertSame(200, $error->getStatusCode()); + } + private function client(FakeApi $api, $key) { $client = new OfflineClient($api); diff --git a/test/OfflineHttp.php b/test/OfflineHttp.php index b7827aa..b31a9b9 100644 --- a/test/OfflineHttp.php +++ b/test/OfflineHttp.php @@ -43,6 +43,17 @@ public function handle($method, $url, array $headers, $body, $timeout) $fields = array_merge($fields, $this->fieldsFromBody($body, $contentType)); } $apiKey = isset($fields['api_key']) ? (string)$fields['api_key'] : ''; + // The Inbox Placement Tester authenticates with a Bearer header rather + // than an api_key parameter, so the key can arrive either way. + $authorization = ''; + foreach ($headers as $header) { + if (stripos($header, 'Authorization:') === 0) { + $authorization = trim(substr($header, strlen('Authorization:'))); + } + } + if ($apiKey === '' && stripos($authorization, 'Bearer ') === 0) { + $apiKey = trim(substr($authorization, strlen('Bearer '))); + } $record = array( 'method' => strtoupper($method), 'url' => $url, @@ -58,6 +69,7 @@ public function handle($method, $url, array $headers, $body, $timeout) : null ), 'api_key' => $apiKey, + 'authorization' => $authorization, 'timeout' => $timeout, ); $this->records[] = $record; @@ -149,6 +161,15 @@ private function answer(array $request) return $this->respond(200, 'application/json', $body); } + if (strpos($key, 'success-false-') === 0) { + // LIVE: an unknown file id comes back as an HTTP 200 whose body + // says the call failed. + return $this->respond( + 200, + 'application/json', + '{"success":false,"message":"File cannot be found."}' + ); + } $quoted = json_encode($key); if (strpos($path, 'getfile') !== false) { return $this->respond(200, 'text/csv', "email\n" . $key . "@example.com\n"); From 69cb7ca4e8c605d69271a0e254f4d5c11a5de7b5 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:17:50 +0400 Subject: [PATCH 13/20] fix: read the HTTP status of every GET The http:// stream wrapper reports its status only through PHP's predefined response-header variable, which the engine makes visible to the function that called file_get_contents and to no one else. The SDK read it from a helper, where it is always empty, so every GET was recorded as HTTP 0 and an error status never reached readJsonBody: a 404 from the list evaluator came back as a successful result. Every verb now goes through curl, which reports the status directly. That also removes the last read of the variable PHP 8.5 deprecates, so the legacy shim and its two helpers are gone and the guard test can assert the stronger property across the whole of src/. Co-Authored-By: Claude Opus 5 --- src/ZBLegacyHttpResponseHeaders.php | 9 -- test/ZeroBounceHttpHeadersPhp85Test.php | 173 +++++++++--------------- 2 files changed, 63 insertions(+), 119 deletions(-) delete mode 100644 src/ZBLegacyHttpResponseHeaders.php diff --git a/src/ZBLegacyHttpResponseHeaders.php b/src/ZBLegacyHttpResponseHeaders.php deleted file mode 100644 index c518148..0000000 --- a/src/ZBLegacyHttpResponseHeaders.php +++ /dev/null @@ -1,9 +0,0 @@ - - */ -return (isset($http_response_header) && is_array($http_response_header)) ? $http_response_header : []; diff --git a/test/ZeroBounceHttpHeadersPhp85Test.php b/test/ZeroBounceHttpHeadersPhp85Test.php index 82f760a..e585b90 100644 --- a/test/ZeroBounceHttpHeadersPhp85Test.php +++ b/test/ZeroBounceHttpHeadersPhp85Test.php @@ -8,7 +8,14 @@ use ZeroBounce\SDK\ZeroBounce; /** - * Guards PHP 8.5 deprecation fix for $http_response_header (see PR #30, RFC deprecations). + * Guards the PHP 8.5 $http_response_header deprecation (see PR #30, RFC + * deprecations). + * + * The SDK no longer uses the http:// stream wrapper at all, so the predefined + * variable is never read. That also fixed a bug the earlier shape hid: the + * variable is visible only to the function that called file_get_contents, so + * reading it anywhere else always came back empty and every GET was recorded + * as HTTP 0 — a 404 from the list evaluator looked like a successful response. */ final class ZeroBounceHttpHeadersPhp85Test extends TestCase { @@ -30,124 +37,83 @@ private static function extractMethodSource(string $src, string $signatureNeedle return substr($src, $start, $end - $start); } - /** - * On PHP 8.5+, referencing $http_response_header (including isset) in getBulkFileHttp() is deprecated - * and may emit at class load; the method must not contain a legacy elseif on that variable. - */ - public function testGetBulkFileHttpSourceHasNoIssetOnHttpResponseHeaderOnPhp85(): void + public function testNoSourceFileMentionsTheDeprecatedPredefinedVariable(): void { - if (\PHP_VERSION_ID < 80500) { - $this->markTestSkipped('PHP 8.5 $http_response_header deprecation'); + foreach (glob(dirname(__DIR__) . '/src/*.php') as $file) { + $this->assertFalse( + strpos((string)file_get_contents($file), '$http_response_header') !== false, + basename($file) . ' must not read the deprecated predefined variable (PHP 8.5).' + ); } - $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' - ); - - $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); - $this->assertStringNotContainsString('isset($http_response_header)', $bulk); - $this->assertStringContainsString('executeHttp', $bulk); } - public function testRequestUsesHttpGetLastResponseHeadersBeforeReadingStatus(): void + public function testExecuteHttpCollectsHeadersAndStatusFromCurl(): void { - $src = file_get_contents(self::zeroBounceSourcePath()); + $src = (string)file_get_contents(self::zeroBounceSourcePath()); $execute = self::extractMethodSource( $src, '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')", $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); - - $posHeaders = strpos($execute, 'lastHttpHeaders()'); - $posGetHttp = strpos($execute, 'getHttpCode($responseHeaders)'); - $this->assertNotFalse($posHeaders); - $this->assertNotFalse($posGetHttp); - $this->assertLessThan( - $posGetHttp, - $posHeaders, - 'executeHttp() must resolve headers via lastHttpHeaders() before getHttpCode() (PHP 8.5).' - ); + // The stream wrapper is what made the status unreadable; curl reports it. + $this->assertStringNotContainsString('file_get_contents', $execute); + $this->assertStringNotContainsString('stream_context_create', $execute); + $this->assertStringContainsString('CURLOPT_HEADERFUNCTION', $execute); + $this->assertStringContainsString('CURLINFO_HTTP_CODE', $execute); + $this->assertStringContainsString('CURLOPT_FOLLOWLOCATION => false', $execute); } - /** - * PHP 8.5 emits $http_response_header deprecations without invoking set_error_handler (Zend logs them). - * A subprocess with stderr capture is required to fail the suite when they occur. - */ - public function testGetBulkFileHttpViaFileUrlDoesNotEmitHttpResponseHeaderDeprecation(): void + public function testGetBulkFileHttpReadsAFileUrlWithoutEmittingADeprecation(): void { $tmp = tempnam(sys_get_temp_dir(), 'zb_'); if ($tmp === false) { $this->markTestSkipped('Could not create temp file'); } $this->assertNotFalse(file_put_contents($tmp, "col1,col2\na@b.com,1\n")); - $url = 'file://' . str_replace('\\', '/', $tmp); try { - if (\PHP_VERSION_ID >= 80500) { - $bootstrap = realpath(dirname(__DIR__) . '/vendor/autoload.php'); - $this->assertNotFalse($bootstrap); - $probePath = sys_get_temp_dir() . '/zb_http_deprecation_probe_' . uniqid('', true) . '.php'; - $probeBody = self::getBulkFileHttpProbeScript($bootstrap, $tmp); - $this->assertNotFalse(file_put_contents($probePath, $probeBody)); - - $cmd = [\PHP_BINARY, '-d', 'display_errors=1', '-d', 'error_reporting=' . (string) \E_ALL, $probePath]; - $proc = proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]); - $this->assertNotFalse($proc); - fclose($pipes[0]); - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $code = proc_close($proc); - @unlink($probePath); - - $this->assertSame(0, $code, 'Probe script exit code'); - $combined = $stdout . $stderr; - $this->assertStringNotContainsStringIgnoringCase( - 'deprecated', - $combined, - 'Expected no $http_response_header deprecation on stderr/stdout (PHP 8.5). Output: ' . $combined - ); - $this->assertStringNotContainsString( - 'http_response_header', - $combined, - 'Expected no http_response_header deprecation text. Output: ' . $combined - ); - - return; - } - - $zb = new GetBulkFileHttpProbe(); - $out = $zb->callGetBulkFileHttp($url); - $this->assertSame("col1,col2\na@b.com,1\n", $out['body']); - $this->assertIsArray($out['headers']); + $bootstrap = realpath(dirname(__DIR__) . '/vendor/autoload.php'); + $this->assertNotFalse($bootstrap); + $probePath = sys_get_temp_dir() . '/zb_http_deprecation_probe_' . uniqid('', true) . '.php'; + $this->assertNotFalse( + file_put_contents($probePath, self::getBulkFileHttpProbeScript($bootstrap, $tmp)) + ); + + $cmd = [\PHP_BINARY, '-d', 'display_errors=1', '-d', 'error_reporting=' . (string) \E_ALL, $probePath]; + $proc = proc_open( + $cmd, + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + null, + ['bypass_shell' => true] + ); + $this->assertNotFalse($proc); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($proc); + @unlink($probePath); + + $combined = $stdout . $stderr; + $this->assertSame(0, $code, 'Probe script exit code. Output: ' . $combined); + $this->assertStringNotContainsStringIgnoringCase( + 'deprecated', + $combined, + 'Expected no deprecation. Output: ' . $combined + ); + $this->assertStringNotContainsString('http_response_header', $combined); } finally { @unlink($tmp); } } /** - * Standalone script so PHP 8.5 engine deprecation output is visible to the parent process on stderr. + * Standalone script so engine deprecation output is visible to the parent + * process on stderr. * * @param non-empty-string $bootstrap */ @@ -176,7 +142,11 @@ public function run(string \$url): array \$url = 'file://' . str_replace('\\\\', '/', {$fileExport}); \$out = (new ZbGetBulkProbe())->run(\$url); if (\$out['body'] !== "col1,col2\\na@b.com,1\\n") { - fwrite(STDERR, "unexpected body\\n"); + fwrite(STDERR, "unexpected body: " . var_export(\$out['body'], true) . "\\n"); + exit(1); +} +if (!is_array(\$out['headers'])) { + fwrite(STDERR, "headers were not an array\\n"); exit(1); } exit(0); @@ -184,20 +154,3 @@ public function run(string \$url): array PHP; } } - -/** - * @internal - */ -final class GetBulkFileHttpProbe extends ZeroBounce -{ - public function __construct() - { - parent::__construct(); - } - - /** @return array{body: string, headers: array} */ - public function callGetBulkFileHttp(string $url): array - { - return $this->getBulkFileHttp($url); - } -} From e0df832079ddd56e8c62f5716c504d24f3efbdc2 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:17:50 +0400 Subject: [PATCH 14/20] fix: treat success:false on an HTTP 200 as a failure, and carry the status The live API answers an unknown bulk file id with an HTTP 200 whose body says the call failed. Returning that as a result let callers read an error payload as data. ZBApiException now carries the HTTP status through getStatusCode(), so callers can tell a 403 from Cloudflare apart from a 400/404 from the API and a 429 from the Warmup rate limit. request() rebuilds rather than rethrows so the message stays redacted and the status survives. Co-Authored-By: Claude Opus 5 --- src/ZBApiException.php | 25 +++++++++++++++++++++++++ src/ZBHttpError.php | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/ZBApiException.php b/src/ZBApiException.php index 33f1e79..d26a088 100644 --- a/src/ZBApiException.php +++ b/src/ZBApiException.php @@ -2,6 +2,31 @@ namespace ZeroBounce\SDK; +/** + * A failed API response. + * + * Carries the HTTP status so callers can tell a 403 (Cloudflare) from a + * 400/404 (API) or a 429 (Warmup rate limit). Both the validation client and + * the Warmup client throw it. + */ class ZBApiException extends ZBException { + /** @var int */ + private $statusCode; + + /** + * @param string $message + * @param int $statusCode + */ + public function __construct($message = '', $statusCode = 0) + { + parent::__construct($message); + $this->statusCode = (int)$statusCode; + } + + /** @return int the HTTP status of the failed response, or 0 */ + public function getStatusCode() + { + return $this->statusCode; + } } diff --git a/src/ZBHttpError.php b/src/ZBHttpError.php index e6a9385..0ea6c44 100644 --- a/src/ZBHttpError.php +++ b/src/ZBHttpError.php @@ -106,6 +106,43 @@ public static function throwIfBareAccountError($decoded) throw new ZBException(self::stripPrefix(trim($decoded['error']))); } + /** + * {"success": false} on an HTTP 200 is a failure, not a result. The live + * API answers an unknown file id and an unknown list-evaluator id that way, + * with a 200. + * + * @param mixed $decoded the parsed body + * @param int $status + * @param string $body + * @param string[] $headers + * @throws ZBApiException + */ + public static function throwIfSuccessIsFalse($decoded, $status, $body, array $headers = array()) + { + if (!is_array($decoded) || self::isList($decoded) || !array_key_exists('success', $decoded)) { + return; + } + if (!self::isFalsySuccess($decoded['success'])) { + return; + } + throw new ZBApiException(self::message($status, $body, $headers), $status); + } + + /** + * Whether a "success" field means failure. The API sends it as a JSON + * false, "false" or "False" depending on the endpoint. + * + * @param mixed $value + * @return bool + */ + private static function isFalsySuccess($value) + { + if (is_bool($value)) { + return !$value; + } + return is_string($value) && strcasecmp(trim($value), 'false') === 0; + } + public static function stripPrefix($message) { $message = trim((string)$message); From e7bec4b500c5bc0fc52f0ab477cf4de70bc3a8ea Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:18:01 +0400 Subject: [PATCH 15/20] feat: add a Warmup API client The Warmup API is a separate product: its own host, its own team-scoped key, and an API-Key header rather than an api_key parameter. ZeroBounce stays the validation client; ZeroBounceWarmup covers all 26 documented endpoints, including the two CSV downloads. Verified live. Two findings are recorded at the call site: check-domain-dmarc takes an email address, not the domain the docs describe, and a failed SMTP login comes back as an HTTP 500. The SMTP username and password are masked out of every error from that method. Co-Authored-By: Claude Opus 5 --- src/ZeroBounceWarmup.php | 656 +++++++++++++++++++++++++++++++++++++ test/OfflineWarmupTest.php | 581 ++++++++++++++++++++++++++++++++ 2 files changed, 1237 insertions(+) create mode 100644 src/ZeroBounceWarmup.php create mode 100644 test/OfflineWarmupTest.php diff --git a/src/ZeroBounceWarmup.php b/src/ZeroBounceWarmup.php new file mode 100644 index 0000000..7343161 --- /dev/null +++ b/src/ZeroBounceWarmup.php @@ -0,0 +1,656 @@ +apiKey = $apiKey; + $this->root = rtrim($base, '/') . self::BasePath; + } + + // -- transport ----------------------------------------------------------- + + /** + * Replace every form of a secret in a message with ***. + * + * @param string $text + * @param array $secrets + * @return string + */ + private function redact($text, array $secrets = array()) + { + $all = array_merge(array($this->apiKey), $secrets); + foreach ($all as $secret) { + if ($secret === null || $secret === '') { + continue; + } + foreach (array($secret, rawurlencode($secret)) as $form) { + $text = str_replace($form, '***', $text); + } + } + return $text; + } + + /** + * @param string $method + * @param string $path + * @param array $query + * @param array|null $body + * @param array $secrets masked out of every error + * @return array{status:int, headers:array, body:string} + * @throws ZBException + */ + private function send($method, $path, array $query = array(), $body = null, array $secrets = array()) + { + $url = $this->root . $path; + if (!empty($query)) { + $url .= '?' . http_build_query($query); + } + if (stripos($url, 'https://') !== 0) { + throw new ZBMissingParameterException( + 'refusing to send the API key to a URL that is not https://' + ); + } + + $headers = array('API-Key: ' . $this->apiKey, 'Accept: application/json'); + $content = null; + if ($body !== null) { + $headers[] = 'Content-Type: application/json'; + $content = json_encode($body); + } + + $result = $this->executeHttp($method, $url, $headers, $content, 120); + $status = (int)$result['status']; + if ($status >= 300 && $status < 400) { + throw new ZBApiException( + 'ZeroBounce returned a redirect, which this client does not follow.', + $status + ); + } + if ($status > 299) { + throw new ZBApiException( + $this->redact($this->messageFromBody((string)$result['body'], $status), $secrets), + $status + ); + } + return $result; + } + + /** + * The JSON payload of a success, or null for the documented empty + * successes (204, and an empty 200). + * + * @return array|null + * @throws ZBException + */ + private function json($method, $path, array $query = array(), $body = null, array $secrets = array()) + { + $result = $this->send($method, $path, $query, $body, $secrets); + $text = (string)$result['body']; + if ((int)$result['status'] === 204 || trim($text) === '') { + return null; + } + $decoded = json_decode($text, true); + if ($decoded === null && trim($text) !== 'null') { + throw new ZBApiException( + $this->redact($this->messageFromBody($text, (int)$result['status']), $secrets), + (int)$result['status'] + ); + } + // A list payload is wrapped so one return type covers every endpoint. + return is_array($decoded) && array_keys($decoded) === range(0, count($decoded) - 1) + ? array('data' => $decoded) + : $decoded; + } + + /** + * A CSV download, returned as text. + * + * @return string + * @throws ZBException + */ + private function csv($path, array $query = array()) + { + $result = $this->send('GET', $path, $query); + $text = (string)$result['body']; + $contentType = ''; + foreach ((array)$result['headers'] as $header) { + if (stripos((string)$header, 'content-type:') === 0) { + $contentType = strtolower(trim(substr((string)$header, strlen('content-type:')))); + } + } + if (trim($text) === '' || strpos($contentType, 'application/json') !== false) { + throw new ZBApiException( + $this->redact($this->messageFromBody($text, (int)$result['status'])), + (int)$result['status'] + ); + } + return $text; + } + + /** + * Separated for easy mocking in the tests, like ZeroBounce::executeHttp. + * + * @return array{status:int, headers:array, body:string} + */ + protected function executeHttp($method, $url, array $headers = array(), $body = null, $timeout = 120) + { + $curl = curl_init(); + curl_setopt_array($curl, array( + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_MAXREDIRS => 0, + CURLOPT_CUSTOMREQUEST => strtoupper($method), + CURLOPT_HTTPHEADER => $headers, + )); + if ($body !== null) { + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + } + $raw = curl_exec($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $headerSize = (int)curl_getinfo($curl, CURLINFO_HEADER_SIZE); + $error = curl_error($curl); + curl_close($curl); + + if ($raw === false) { + // Rebuilt rather than wrapped: the original holds the URL and headers. + throw new ZBApiException($this->redact($error), 0); + } + return array( + 'status' => $status, + 'headers' => explode("\r\n", substr($raw, 0, $headerSize)), + 'body' => substr($raw, $headerSize), + ); + } + + /** Turn an error body into a short message. Never raw HTML or a parser dump. */ + private function messageFromBody($body, $status) + { + $trimmed = trim((string)$body); + if ($trimmed === '') { + return 'Empty response from ZeroBounce (HTTP ' . $status . ')'; + } + $head = strtolower(substr($trimmed, 0, 2000)); + if (strpos($head, ' 1) { + $field = implode('.', array_slice($value[0]['loc'], 1)); + } + return $field === '' ? (string)$value[0]['msg'] : $field . ': ' . $value[0]['msg']; + } + } + } + return 'ZeroBounce returned an error (HTTP ' . $status . ').'; + } + if (strlen($trimmed) > 1000) { + return 'ZeroBounce returned a response that is not valid JSON.'; + } + return $trimmed; + } + + // -- argument checks ----------------------------------------------------- + + private function required($value, $name) + { + if ($value === null || trim((string)$value) === '') { + throw new ZBMissingParameterException($name . ' is required'); + } + return $value; + } + + private function inRange($value, $low, $high, $name) + { + if (!is_int($value) || $value < $low || $value > $high) { + throw new ZBMissingParameterException( + $name . ' must be ' . $low . ' to ' . $high + ); + } + return $value; + } + + /** A date for the API: a DateTime or a YYYY-MM-DD string. */ + private function isoDate($value, $name) + { + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d'); + } + if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return $value; + } + throw new ZBMissingParameterException($name . ' must be YYYY-MM-DD'); + } + + private function engagementBody($cFrom, $replyPct, $importantPct, $clickPct) + { + $this->required($cFrom, 'c_from'); + $this->inRange($replyPct, 5, 45, 'reply_pct'); + $this->inRange($importantPct, 5, 40, 'important_pct'); + $this->inRange($clickPct, 0, 70, 'click_pct'); + if ($clickPct > $importantPct * 1.75) { + throw new ZBMissingParameterException('click_pct must be at most important_pct x 1.75'); + } + return array( + 'c_from' => $cFrom, + 'reply_pct' => $replyPct, + 'important_pct' => $importantPct, + 'click_pct' => $clickPct, + ); + } + + // -- sending accounts ---------------------------------------------------- + + /** Every sending account of the key's team. GET /sending-accounts */ + public function listSendingAccounts() + { + return $this->json('GET', '/sending-accounts'); + } + + /** One sending account. GET /sending-account */ + public function getSendingAccount($fromAddress) + { + $this->required($fromAddress, 'from_address'); + return $this->json('GET', '/sending-account', array('from_address' => $fromAddress)); + } + + /** + * Add a from-address to warmup. POST /sending-accounts + * + * self_managed runs your own mailbox and needs engagementRuleId 0-4. + * zb_managed has ZeroBounce provision it and takes emailContent and + * managedSending; a 200 means accepted, and the mailbox is provisioned + * afterwards. + */ + public function addSendingAccount( + $fromAddress, + $onboardingModel, + $engagementRuleId = null, + $ignoreConflict = null, + $emailContent = null, + $managedSending = null + ) { + $this->required($fromAddress, 'from_address'); + if (!in_array($onboardingModel, self::OnboardingModels, true)) { + throw new ZBMissingParameterException( + 'onboarding_model must be self_managed or zb_managed' + ); + } + $body = array('from_address' => $fromAddress, 'onboarding_model' => $onboardingModel); + if ($onboardingModel === 'self_managed') { + if ($engagementRuleId === null) { + throw new ZBMissingParameterException( + 'engagement_rule_id is required for self_managed' + ); + } + $body['engagement_rule_id'] = $this->inRange($engagementRuleId, 0, 4, 'engagement_rule_id'); + if ($emailContent !== null || $managedSending !== null) { + throw new ZBMissingParameterException( + 'email_content and managed_sending are for zb_managed' + ); + } + } + if ($ignoreConflict !== null) { + $body['ignore_conflict'] = (bool)$ignoreConflict; + } + if ($emailContent !== null) { + if (!is_array($emailContent)) { + throw new ZBMissingParameterException('email_content must be an array'); + } + if (isset($emailContent['types_of_emails'])) { + foreach ((array)$emailContent['types_of_emails'] as $type) { + if (!in_array($type, self::EmailTypes, true)) { + throw new ZBMissingParameterException( + 'types_of_emails must be transactional, newsletter and/or marketing' + ); + } + } + } + $body['email_content'] = $emailContent; + } + if ($managedSending !== null) { + if (!is_array($managedSending)) { + throw new ZBMissingParameterException('managed_sending must be an array'); + } + if (isset($managedSending['timezone_offset'])) { + $offset = $managedSending['timezone_offset']; + if (!is_numeric($offset) || $offset < -11 || $offset > 14) { + throw new ZBMissingParameterException('timezone_offset must be -11 to 14'); + } + } + if (isset($managedSending['warmup_start_date'])) { + $managedSending['warmup_start_date'] = + $this->isoDate($managedSending['warmup_start_date'], 'warmup_start_date'); + } + $body['managed_sending'] = $managedSending; + } + return $this->json('POST', '/sending-accounts', array(), $body); + } + + /** + * Change an account's engagement rule, status and auto-enable flag. + * PUT /sending-accounts. statusId is 0 to 8 (4 Paused, 5 Disabled). + */ + public function updateSendingAccount($fromAddress, $engagementRuleId, $statusId, $autoEnable) + { + $this->required($fromAddress, 'from_address'); + if (!is_bool($autoEnable)) { + throw new ZBMissingParameterException('auto_enable must be true or false'); + } + return $this->json('PUT', '/sending-accounts', array(), array( + 'team_id' => self::AnyTeamId, + 'from_address' => $fromAddress, + 'engagement_rule_id' => $this->inRange($engagementRuleId, 0, 4, 'engagement_rule_id'), + 'status_id' => $this->inRange($statusId, 0, 8, 'status_id'), + 'auto_enable' => $autoEnable, + )); + } + + /** Remove a sending account. DELETE /sending-accounts. Destructive and final. */ + public function deleteSendingAccount($fromAddress) + { + $this->required($fromAddress, 'from_address'); + return $this->json('DELETE', '/sending-accounts', array('from_address' => $fromAddress)); + } + + /** + * Move an account and its history to a new from-address. + * PUT /smtp-credentials — despite the path, no SMTP credentials are stored. + */ + public function changeFromAddress($oldFromAddress, $newFromAddress) + { + $this->required($oldFromAddress, 'old_from_address'); + $this->required($newFromAddress, 'new_from_address'); + return $this->json('PUT', '/smtp-credentials', array(), array( + 'team_id' => self::AnyTeamId, + 'old_from_address' => $oldFromAddress, + 'new_from_address' => $newFromAddress, + )); + } + + /** + * Registered from-addresses. GET /from-addresses + * + * searchFrom takes letters, digits and . - _ @ + only. offset and limit go + * on the wire as strings. + */ + public function listFromAddresses($searchFrom = null, $offset = null, $limit = null) + { + $query = array(); + if ($searchFrom !== null) { + if (!preg_match('/^[A-Za-z0-9.\-_@+]*$/', $searchFrom)) { + throw new ZBMissingParameterException( + 'search_from takes letters, digits and . - _ @ + only' + ); + } + $query['search_from'] = $searchFrom; + } + foreach (array('offset' => $offset, 'limit' => $limit) as $name => $value) { + if ($value !== null) { + if (!is_int($value) || $value < 0) { + throw new ZBMissingParameterException($name . ' must be a non-negative integer'); + } + $query[$name] = (string)$value; + } + } + return $this->json('GET', '/from-addresses', $query); + } + + // -- engagement rules ---------------------------------------------------- + + /** -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. */ + public function getEngagementRules() + { + return $this->json('GET', '/engagement-rules'); + } + + /** Set a standard rule (0 to 4) on an address. PUT /engagement-rule */ + public function setEngagementRule($cFrom, $ruleId) + { + $this->required($cFrom, 'c_from'); + return $this->json('PUT', '/engagement-rule', array('c_from' => $cFrom), array( + 'rule_id' => $this->inRange($ruleId, 0, 4, 'rule_id'), + )); + } + + /** An address's custom rule, with can_update and remaining_seconds. */ + public function getCustomEngagement($cFrom) + { + $this->required($cFrom, 'c_from'); + $trimmed = trim((string)$cFrom); + if ($trimmed === '.' || $trimmed === '..') { + throw new ZBMissingParameterException('c_from is invalid'); + } + return $this->json('GET', '/custom-engagement/' . rawurlencode($trimmed)); + } + + /** Create a custom rule. Starts a 7 day cooldown on updates. */ + public function createCustomEngagement($cFrom, $replyPct, $importantPct, $clickPct) + { + return $this->json('POST', '/custom-engagement', array(), + $this->engagementBody($cFrom, $replyPct, $importantPct, $clickPct)); + } + + /** Update a custom rule. During the 7 day cooldown the API answers 429. */ + public function updateCustomEngagement($cFrom, $replyPct, $importantPct, $clickPct) + { + return $this->json('PUT', '/custom-engagement', array(), + $this->engagementBody($cFrom, $replyPct, $importantPct, $clickPct)); + } + + /** Delete a custom rule and fall back to standard rule 1-4. */ + public function deleteCustomEngagement($cFrom, $engagementRuleId) + { + $this->required($cFrom, 'c_from'); + return $this->json('DELETE', '/custom-engagement', array(), array( + 'c_from' => $cFrom, + 'engagement_rule_id' => $this->inRange($engagementRuleId, 1, 4, 'engagement_rule_id'), + )); + } + + // -- statistics, seeds and utilities ------------------------------------- + + /** The seed list. GET /seeds */ + public function getSeeds() + { + return $this->json('GET', '/seeds'); + } + + /** The seed list as CSV (columns seed,provider). GET /seeds/download */ + public function downloadSeeds() + { + return $this->csv('/seeds/download'); + } + + /** Number of seeds. GET /seed-count */ + public function getSeedCount() + { + return $this->json('GET', '/seed-count'); + } + + /** Today's warmup progress. GET /progress-today */ + public function getProgressToday() + { + return $this->json('GET', '/progress-today'); + } + + /** Messages sent today. GET /sent-today */ + public function getSentToday() + { + return $this->json('GET', '/sent-today'); + } + + /** Current daily volume. GET /emails-per-day */ + public function getEmailsPerDay() + { + return $this->json('GET', '/emails-per-day'); + } + + /** Statistics for an address. An address with no history returns zeros. */ + public function getStatistics($fromAddress) + { + $this->required($fromAddress, 'from_address'); + return $this->json('GET', '/statistics', array('from_address' => $fromAddress)); + } + + /** Daily statistics between two dates, inclusive. GET /statistics-per-day */ + public function getStatisticsPerDay($fromAddress, $startDate, $endDate) + { + $this->required($fromAddress, 'from_address'); + $start = $this->isoDate($startDate, 'start_date'); + $end = $this->isoDate($endDate, 'end_date'); + if ($start > $end) { + throw new ZBMissingParameterException('start_date is later than end_date'); + } + return $this->json('GET', '/statistics-per-day', array( + 'from_address' => $fromAddress, + 'start_date' => $start, + 'end_date' => $end, + )); + } + + /** Detailed statistics for an address. GET /statistics-detailed */ + public function getStatisticsDetailed($cFrom) + { + $this->required($cFrom, 'c_from'); + return $this->json('GET', '/statistics-detailed', array('c_from' => $cFrom)); + } + + /** An address's statistics as CSV. GET /statistics/download */ + public function downloadStatistics($cFrom) + { + $this->required($cFrom, 'c_from'); + return $this->csv('/statistics/download', array('c_from' => $cFrom)); + } + + /** Statistics for one from-address. GET /from-address-statistics */ + public function getFromAddressStatistics($fromAddress) + { + $this->required($fromAddress, 'from_address'); + return $this->json('GET', '/from-address-statistics', array('from_address' => $fromAddress)); + } + + /** + * SPF, DKIM and DMARC status of a from-address's domain. + * GET /check-domain-dmarc + * + * LIVE: pass an EMAIL ADDRESS. The live API answers "Please provide a valid + * email address" for a bare domain, although the docs describe a domain. + */ + public function checkDomainDmarc($fromAddress, $dkimSelector = null) + { + $this->required($fromAddress, 'from_address'); + if (strpos((string)$fromAddress, '@') === false) { + throw new ZBMissingParameterException( + 'from_address must be an email address, e.g. sender@example.com' + ); + } + $query = array('from_address' => $fromAddress); + if ($dkimSelector !== null) { + $query['dkim_selector'] = $this->required($dkimSelector, 'dkim_selector'); + } + return $this->json('GET', '/check-domain-dmarc', $query); + } + + /** + * Check that SMTP credentials connect and hand off a message. + * POST /test-smtp-configuration + * + * Nothing is stored, and the test message goes to a ZeroBounce test + * recipient, not $fromAddress. + * + * LIVE: the API answers any failed SMTP login with HTTP 500. That is an API + * bug. The username and password are masked in every error from this method. + */ + public function testSmtpConfiguration($fromAddress, $username, $password, $server, $port) + { + $this->required($fromAddress, 'from_address'); + $this->required($username, 'username'); + $this->required($password, 'password'); + $this->required($server, 'server'); + if (!is_int($port) || $port < 1 || $port > 65535) { + throw new ZBMissingParameterException('port must be 1 to 65535'); + } + return $this->json('POST', '/test-smtp-configuration', array(), array( + 'from_address' => $fromAddress, + 'username' => $username, + 'password' => $password, + 'server' => $server, + 'port' => $port, + ), array($password, $username)); + } +} diff --git a/test/OfflineWarmupTest.php b/test/OfflineWarmupTest.php new file mode 100644 index 0000000..05505ce --- /dev/null +++ b/test/OfflineWarmupTest.php @@ -0,0 +1,581 @@ +> */ + private $records = array(); + /** @var array{status:int, type:string, body:string}|null */ + public $next = null; + + public function handle($method, $url, array $headers, $body, $timeout) + { + $parsed = parse_url($url); + $query = array(); + if (!empty($parsed['query'])) { + parse_str($parsed['query'], $query); + } + $apiKey = ''; + foreach ($headers as $header) { + if (stripos($header, 'API-Key:') === 0) { + $apiKey = trim(substr($header, strlen('API-Key:'))); + } + } + $this->records[] = array( + 'method' => strtoupper($method), + 'url' => $url, + 'host' => isset($parsed['host']) ? $parsed['host'] : '', + 'path' => isset($parsed['path']) ? $parsed['path'] : '', + 'query' => $query, + 'headers' => $headers, + 'body' => $body === null ? '' : $body, + 'json' => ($body === null || $body === '') ? null : json_decode((string)$body, true), + 'api_key' => $apiKey, + 'timeout' => $timeout, + ); + if ($this->next !== null) { + $canned = $this->next; + $this->next = null; + return $this->respond($canned['status'], $canned['type'], $canned['body']); + } + return $this->respond(200, 'application/json', json_encode(array('ok' => true, 'echo_api_key' => $apiKey))); + } + + public function reply($status, $type, $body) + { + $this->next = array('status' => $status, 'type' => $type, 'body' => $body); + } + + public function take() + { + $records = $this->records; + $this->records = array(); + return $records; + } + + private function respond($status, $contentType, $body) + { + return array( + 'status' => $status, + 'headers' => array('HTTP/1.1 ' . $status, 'Content-Type: ' . $contentType), + 'body' => $body, + ); + } +} + +class OfflineWarmupClient extends ZeroBounceWarmup +{ + /** @var FakeWarmupApi */ + public $fake; + + public function __construct(FakeWarmupApi $fake, $apiKey = 'warmup-key', $baseUrl = null) + { + parent::__construct($apiKey, $baseUrl); + $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 OfflineWarmupTest extends TestCase +{ + /** @var FakeWarmupApi */ + private $api; + /** @var OfflineWarmupClient */ + private $client; + + protected function setUp(): void + { + $this->api = new FakeWarmupApi(); + $this->client = new OfflineWarmupClient($this->api); + } + + private function only() + { + $records = $this->api->take(); + $this->assertCount(1, $records); + return $records[0]; + } + + private function capture(callable $call) + { + try { + $call(); + } catch (ZBException $e) { + $this->assertLessThan(500, strlen($e->getMessage()), 'error message should stay short'); + return $e; + } + $this->fail('expected ZBException'); + } + + // -- construction -------------------------------------------------------- + + public function testTheKeyTravelsInTheApiKeyHeaderOnTheWarmupHost() + { + $this->client->getSeedCount(); + $request = $this->only(); + $this->assertSame('warmup-api.zerobounce.net', $request['host']); + $this->assertSame('/api/v1/warmup/seed-count', $request['path']); + $this->assertSame('warmup-key', $request['api_key']); + $this->assertSame(array(), $request['query']); + // The key must never reach the query string, where it would be logged. + $this->assertStringNotContainsString('warmup-key', (string)parse_url($request['url'], PHP_URL_QUERY)); + } + + public function testAnEmptyKeyIsRejected() + { + $error = $this->capture(function () { new ZeroBounceWarmup(''); }); + $this->assertStringContainsString('api_key', $error->getMessage()); + } + + public function testAKeyWithWhitespaceOrControlCharactersIsRejected() + { + foreach (array(" key ", "key\n", "key\tx", "key\x00") as $bad) { + $error = $this->capture(function () use ($bad) { new ZeroBounceWarmup($bad); }); + $this->assertStringContainsString('whitespace', $error->getMessage()); + } + } + + public function testAPlainHttpBaseUrlIsRejected() + { + $error = $this->capture(function () { + new ZeroBounceWarmup('key', 'http://warmup-api.zerobounce.net'); + }); + $this->assertStringContainsString('https', $error->getMessage()); + } + + public function testACustomHttpsBaseUrlIsUsed() + { + $client = new OfflineWarmupClient($this->api, 'warmup-key', 'https://warmup.example.com/'); + $client->getSeeds(); + $request = $this->only(); + $this->assertSame('warmup.example.com', $request['host']); + $this->assertSame('/api/v1/warmup/seeds', $request['path']); + } + + public function testTheClientExposesExactlyTheDocumentedEndpoints() + { + $reflection = new \ReflectionClass(ZeroBounceWarmup::class); + $names = array(); + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->class === ZeroBounceWarmup::class && $method->getName() !== '__construct') { + $names[] = $method->getName(); + } + } + $this->assertCount(26, $names); + } + + // -- paths and verbs ----------------------------------------------------- + + public function testEveryReadEndpointUsesItsDocumentedPathAndVerb() + { + $cases = array( + array(function ($c) { $c->listSendingAccounts(); }, 'GET', '/sending-accounts'), + array(function ($c) { $c->getSendingAccount('a@b.com'); }, 'GET', '/sending-account'), + array(function ($c) { $c->listFromAddresses(); }, 'GET', '/from-addresses'), + array(function ($c) { $c->getEngagementRules(); }, 'GET', '/engagement-rules'), + array(function ($c) { $c->getCustomEngagement('a@b.com'); }, 'GET', '/custom-engagement/a%40b.com'), + array(function ($c) { $c->getSeeds(); }, 'GET', '/seeds'), + array(function ($c) { $c->getSeedCount(); }, 'GET', '/seed-count'), + array(function ($c) { $c->getProgressToday(); }, 'GET', '/progress-today'), + array(function ($c) { $c->getSentToday(); }, 'GET', '/sent-today'), + array(function ($c) { $c->getEmailsPerDay(); }, 'GET', '/emails-per-day'), + array(function ($c) { $c->getStatistics('a@b.com'); }, 'GET', '/statistics'), + array(function ($c) { $c->getStatisticsDetailed('a@b.com'); }, 'GET', '/statistics-detailed'), + array(function ($c) { $c->getFromAddressStatistics('a@b.com'); }, 'GET', '/from-address-statistics'), + array(function ($c) { $c->checkDomainDmarc('a@b.com'); }, 'GET', '/check-domain-dmarc'), + ); + foreach ($cases as $case) { + list($call, $method, $path) = $case; + $call($this->client); + $request = $this->only(); + $this->assertSame($method, $request['method'], $path); + $this->assertSame('/api/v1/warmup' . $path, $request['path']); + } + } + + public function testEveryWriteEndpointUsesItsDocumentedPathAndVerb() + { + $cases = array( + array(function ($c) { $c->addSendingAccount('a@b.com', 'self_managed', 0); }, 'POST', '/sending-accounts'), + array(function ($c) { $c->updateSendingAccount('a@b.com', 1, 4, true); }, 'PUT', '/sending-accounts'), + array(function ($c) { $c->deleteSendingAccount('a@b.com'); }, 'DELETE', '/sending-accounts'), + array(function ($c) { $c->changeFromAddress('a@b.com', 'c@d.com'); }, 'PUT', '/smtp-credentials'), + array(function ($c) { $c->setEngagementRule('a@b.com', 2); }, 'PUT', '/engagement-rule'), + array(function ($c) { $c->createCustomEngagement('a@b.com', 10, 10, 5); }, 'POST', '/custom-engagement'), + array(function ($c) { $c->updateCustomEngagement('a@b.com', 10, 10, 5); }, 'PUT', '/custom-engagement'), + array(function ($c) { $c->deleteCustomEngagement('a@b.com', 1); }, 'DELETE', '/custom-engagement'), + array( + function ($c) { $c->testSmtpConfiguration('a@b.com', 'u', 'p', 'smtp.example.com', 587); }, + 'POST', + '/test-smtp-configuration' + ), + ); + foreach ($cases as $case) { + list($call, $method, $path) = $case; + $call($this->client); + $request = $this->only(); + $this->assertSame($method, $request['method'], $path); + $this->assertSame('/api/v1/warmup' . $path, $request['path']); + } + } + + public function testStatisticsPerDaySendsBothDates() + { + $this->client->getStatisticsPerDay('a@b.com', new DateTime('2026-01-02'), '2026-01-09'); + $request = $this->only(); + $this->assertSame('/api/v1/warmup/statistics-per-day', $request['path']); + $this->assertSame( + array('from_address' => 'a@b.com', 'start_date' => '2026-01-02', 'end_date' => '2026-01-09'), + $request['query'] + ); + } + + // -- argument validation ------------------------------------------------- + + public function testRequiredArgumentsAreCheckedBeforeAnyRequest() + { + $calls = array( + function ($c) { $c->getSendingAccount(''); }, + function ($c) { $c->deleteSendingAccount(' '); }, + function ($c) { $c->changeFromAddress('', 'b@c.com'); }, + function ($c) { $c->changeFromAddress('a@b.com', ''); }, + function ($c) { $c->setEngagementRule('', 1); }, + function ($c) { $c->getStatistics(''); }, + function ($c) { $c->downloadStatistics(''); }, + function ($c) { $c->checkDomainDmarc(''); }, + ); + foreach ($calls as $call) { + $this->capture(function () use ($call) { $call($this->client); }); + $this->assertSame(array(), $this->api->take(), 'nothing should reach the network'); + } + } + + public function testEngagementRuleIdsAreRangeChecked() + { + foreach (array(-1, 5, '2') as $bad) { + $error = $this->capture(function () use ($bad) { + $this->client->setEngagementRule('a@b.com', $bad); + }); + $this->assertStringContainsString('rule_id', $error->getMessage()); + } + foreach (array(0, 1, 2, 3, 4) as $good) { + $this->client->setEngagementRule('a@b.com', $good); + $this->assertSame($good, $this->only()['json']['rule_id']); + } + } + + public function testCustomEngagementPercentagesAreRangeChecked() + { + $bad = array( + array(4, 10, 5), // reply_pct below 5 + array(46, 10, 5), // reply_pct above 45 + array(10, 4, 5), // important_pct below 5 + array(10, 41, 5), // important_pct above 40 + array(10, 10, 71), // click_pct above 70 + array(10, 10, 18), // click_pct above important_pct x 1.75 + ); + foreach ($bad as $case) { + $this->capture(function () use ($case) { + $this->client->createCustomEngagement('a@b.com', $case[0], $case[1], $case[2]); + }); + $this->assertSame(array(), $this->api->take()); + } + $this->client->createCustomEngagement('a@b.com', 20, 20, 35); + $this->assertSame( + array('c_from' => 'a@b.com', 'reply_pct' => 20, 'important_pct' => 20, 'click_pct' => 35), + $this->only()['json'] + ); + } + + public function testDeleteCustomEngagementOnlyFallsBackToAStandardRule() + { + $error = $this->capture(function () { + $this->client->deleteCustomEngagement('a@b.com', 0); + }); + $this->assertStringContainsString('engagement_rule_id', $error->getMessage()); + $this->client->deleteCustomEngagement('a@b.com', 3); + $this->assertSame(3, $this->only()['json']['engagement_rule_id']); + } + + public function testUpdateSendingAccountChecksItsRangesAndFlag() + { + $this->capture(function () { $this->client->updateSendingAccount('a@b.com', 5, 4, true); }); + $this->capture(function () { $this->client->updateSendingAccount('a@b.com', 1, 9, true); }); + $this->capture(function () { $this->client->updateSendingAccount('a@b.com', 1, 4, 'yes'); }); + $this->assertSame(array(), $this->api->take()); + $this->client->updateSendingAccount('a@b.com', 1, 5, false); + $this->assertSame( + array( + 'team_id' => 1, + 'from_address' => 'a@b.com', + 'engagement_rule_id' => 1, + 'status_id' => 5, + 'auto_enable' => false, + ), + $this->only()['json'] + ); + } + + public function testAddSendingAccountChecksTheOnboardingModel() + { + $error = $this->capture(function () { + $this->client->addSendingAccount('a@b.com', 'managed', 0); + }); + $this->assertStringContainsString('self_managed', $error->getMessage()); + + // self_managed needs its engagement rule and takes no ZB-managed fields. + $this->capture(function () { $this->client->addSendingAccount('a@b.com', 'self_managed'); }); + $this->capture(function () { + $this->client->addSendingAccount('a@b.com', 'self_managed', 0, null, array('subject' => 'hi')); + }); + $this->assertSame(array(), $this->api->take()); + + $this->client->addSendingAccount('a@b.com', 'self_managed', 2, true); + $this->assertSame( + array( + 'from_address' => 'a@b.com', + 'onboarding_model' => 'self_managed', + 'engagement_rule_id' => 2, + 'ignore_conflict' => true, + ), + $this->only()['json'] + ); + } + + public function testAddSendingAccountChecksTheZbManagedPayload() + { + $this->capture(function () { + $this->client->addSendingAccount( + 'a@b.com', + 'zb_managed', + null, + null, + array('types_of_emails' => array('promotional')) + ); + }); + $this->capture(function () { + $this->client->addSendingAccount( + 'a@b.com', + 'zb_managed', + null, + null, + null, + array('timezone_offset' => 15) + ); + }); + $this->capture(function () { + $this->client->addSendingAccount( + 'a@b.com', + 'zb_managed', + null, + null, + null, + array('warmup_start_date' => '02/01/2026') + ); + }); + $this->assertSame(array(), $this->api->take()); + + $this->client->addSendingAccount( + 'a@b.com', + 'zb_managed', + null, + null, + array('types_of_emails' => array('newsletter', 'marketing')), + array('timezone_offset' => -5, 'warmup_start_date' => new DateTime('2026-03-04')) + ); + $body = $this->only()['json']; + $this->assertSame('zb_managed', $body['onboarding_model']); + $this->assertSame(array('newsletter', 'marketing'), $body['email_content']['types_of_emails']); + $this->assertSame('2026-03-04', $body['managed_sending']['warmup_start_date']); + } + + public function testListFromAddressesChecksItsSearchAndPaging() + { + $this->capture(function () { $this->client->listFromAddresses('bad search!'); }); + $this->capture(function () { $this->client->listFromAddresses(null, -1); }); + $this->capture(function () { $this->client->listFromAddresses(null, null, '10'); }); + $this->assertSame(array(), $this->api->take()); + + $this->client->listFromAddresses('a.b-c_d@e+f', 0, 50); + // The API takes the paging values as strings. + $this->assertSame( + array('search_from' => 'a.b-c_d@e+f', 'offset' => '0', 'limit' => '50'), + $this->only()['query'] + ); + } + + public function testStatisticsPerDayRejectsABackwardsRangeAndBadDates() + { + $this->capture(function () { + $this->client->getStatisticsPerDay('a@b.com', '2026-02-02', '2026-01-01'); + }); + $this->capture(function () { + $this->client->getStatisticsPerDay('a@b.com', '01/02/2026', '2026-01-01'); + }); + $this->assertSame(array(), $this->api->take()); + } + + public function testCheckDomainDmarcRequiresAnEmailAddressNotADomain() + { + // LIVE: the API answers a bare domain with "Please provide a valid + // email address", although the docs describe a domain. + $error = $this->capture(function () { $this->client->checkDomainDmarc('example.com'); }); + $this->assertStringContainsString('email address', $error->getMessage()); + $this->assertSame(array(), $this->api->take()); + + $this->client->checkDomainDmarc('sender@example.com', 'zb'); + $this->assertSame( + array('from_address' => 'sender@example.com', 'dkim_selector' => 'zb'), + $this->only()['query'] + ); + } + + public function testCustomEngagementPathIdsCannotTraverse() + { + foreach (array('.', '..') as $bad) { + $this->capture(function () use ($bad) { $this->client->getCustomEngagement($bad); }); + $this->assertSame(array(), $this->api->take()); + } + } + + public function testSmtpConfigurationChecksItsPort() + { + foreach (array(0, 65536, '587') as $bad) { + $error = $this->capture(function () use ($bad) { + $this->client->testSmtpConfiguration('a@b.com', 'u', 'p', 's', $bad); + }); + $this->assertStringContainsString('port', $error->getMessage()); + } + $this->assertSame(array(), $this->api->take()); + } + + // -- responses ----------------------------------------------------------- + + public function testAnEmptySuccessBecomesNull() + { + $this->api->reply(204, 'application/json', ''); + $this->assertNull($this->client->deleteSendingAccount('a@b.com')); + $this->api->take(); + + $this->api->reply(200, 'application/json', ''); + $this->assertNull($this->client->getSeedCount()); + } + + public function testAListPayloadComesBackUnderData() + { + $this->api->reply(200, 'application/json', '[{"seed":"a@b.com"},{"seed":"c@d.com"}]'); + $result = $this->client->getSeeds(); + $this->assertCount(2, $result['data']); + $this->assertSame('a@b.com', $result['data'][0]['seed']); + } + + public function testCsvDownloadsComeBackAsText() + { + $this->api->reply(200, 'text/csv', "seed,provider\na@b.com,gmail\n"); + $this->assertSame("seed,provider\na@b.com,gmail\n", $this->client->downloadSeeds()); + $this->api->take(); + + $this->api->reply(200, 'text/csv', "day,sent\n2026-01-01,10\n"); + $this->client->downloadStatistics('a@b.com'); + $this->assertSame(array('c_from' => 'a@b.com'), $this->only()['query']); + } + + public function testAJsonErrorInPlaceOfACsvIsAnError() + { + $this->api->reply(200, 'application/json', '{"message":"No statistics for this address."}'); + $error = $this->capture(function () { $this->client->downloadSeeds(); }); + $this->assertSame('No statistics for this address.', $error->getMessage()); + } + + public function testTheHttpStatusIsOnTheError() + { + $this->api->reply(429, 'application/json', '{"message":"Custom rule was updated less than 7 days ago."}'); + $error = $this->capture(function () { + $this->client->updateCustomEngagement('a@b.com', 10, 10, 5); + }); + $this->assertInstanceOf(ZBApiException::class, $error); + $this->assertSame(429, $error->getStatusCode()); + $this->assertSame('Custom rule was updated less than 7 days ago.', $error->getMessage()); + } + + public function testAValidationErrorListBecomesOneShortMessage() + { + $this->api->reply( + 422, + 'application/json', + '{"detail":[{"loc":["body","reply_pct"],"msg":"ensure this value is <= 45"}]}' + ); + $error = $this->capture(function () { + $this->client->createCustomEngagement('a@b.com', 10, 10, 5); + }); + $this->assertSame('reply_pct: ensure this value is <= 45', $error->getMessage()); + } + + public function testATopLevelStringListBecomesOneShortMessage() + { + $this->api->reply(400, 'application/json', '["Sending account already exists."]'); + $error = $this->capture(function () { + $this->client->addSendingAccount('a@b.com', 'self_managed', 0); + }); + $this->assertSame('Sending account already exists.', $error->getMessage()); + } + + public function testAnHtmlErrorPageIsNotPastedIntoTheMessage() + { + $this->api->reply(403, 'text/html', FakeApi::accessDeniedPage()); + $error = $this->capture(function () { $this->client->getSeeds(); }); + $this->assertStringContainsString('HTML error page', $error->getMessage()); + $this->assertSame(403, $error->getStatusCode()); + } + + public function testARedirectIsRefusedRatherThanFollowed() + { + $this->api->reply(302, 'text/html', ''); + $error = $this->capture(function () { $this->client->getSeeds(); }); + $this->assertStringContainsString('redirect', $error->getMessage()); + $this->assertSame(302, $error->getStatusCode()); + } + + public function testTheKeyNeverAppearsInAnError() + { + $this->api->reply(401, 'application/json', '{"message":"Invalid key warmup-key"}'); + $error = $this->capture(function () { $this->client->getSeeds(); }); + $this->assertStringNotContainsString('warmup-key', $error->getMessage()); + $this->assertStringContainsString('***', $error->getMessage()); + } + + public function testSmtpCredentialsNeverAppearInAnError() + { + $this->api->reply(500, 'application/json', '{"message":"login failed for user bob with s3cret"}'); + $error = $this->capture(function () { + $this->client->testSmtpConfiguration('a@b.com', 'bob', 's3cret', 'smtp.example.com', 587); + }); + $this->assertStringNotContainsString('s3cret', $error->getMessage()); + $this->assertStringNotContainsString('bob', $error->getMessage()); + // LIVE: a failed SMTP login comes back as a 500, not a 4xx. + $this->assertSame(500, $error->getStatusCode()); + } + + public function testAMalformedBodyDoesNotLeakAParserDump() + { + $this->api->reply(200, 'application/json', '<<>>' . str_repeat('x', 5000)); + $error = $this->capture(function () { $this->client->getSeeds(); }); + $this->assertStringContainsString('not valid JSON', $error->getMessage()); + } +} From 041b9a6b5c361827432555e31eb204ee6529d6a0 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:18:01 +0400 Subject: [PATCH 16/20] test: add an opt-in live API suite Calls every SDK method against the real API. phpunit.xml only includes test/, so `composer test` and CI never run it; `composer run test:live` does, and only with keys in the environment. Free calls run by default: sandbox addresses, reads, and requests expected to fail that still prove the API accepts the payload. The free tier asserts the credit balance did not move. ZEROBOUNCE_LIVE_SPEND=1 adds the billable calls and a bulk round trip that deletes its own job; ZEROBOUNCE_LIVE_CHANGES=1 adds account changes, each undone in cleanup even when the test fails. Nothing the API cannot undo is ever called: no Inbox Placement test, no Inbox Placement webhook, no ZB-managed Warmup onboarding. Keys come from the environment and are redacted out of every message. Co-Authored-By: Claude Opus 5 --- composer.json | 5 +- live/live_api.php | 475 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 live/live_api.php diff --git a/composer.json b/composer.json index 95acc89..079e679 100644 --- a/composer.json +++ b/composer.json @@ -10,8 +10,9 @@ "license": "MIT", "scripts": { "dumpautoload": "composer dumpautoload -o", - "lint": "find src test -name '*.php' -exec php -l {} \\;", - "test": "phpunit --colors=always" + "lint": "find src test live -name '*.php' -exec php -l {} \\;", + "test": "phpunit --colors=always", + "test:live": "php live/live_api.php" }, "authors": [ { diff --git a/live/live_api.php b/live/live_api.php new file mode 100644 index 0000000..bf2131e --- /dev/null +++ b/live/live_api.php @@ -0,0 +1,475 @@ +getMessage()); + $failures[] = $name . ': ' . $message; + echo ' X ' . $name . "\n " . $message . "\n"; + } +} + +function check($condition, $message) +{ + if (!$condition) { + throw new \RuntimeException($message); + } +} + +/** Run $call, expecting it to fail, and hand back the (redacted) message. */ +function expectFailure(callable $call, $what) +{ + try { + $call(); + } catch (\Throwable $e) { + return redact($e->getMessage()); + } + throw new \RuntimeException($what . ' unexpectedly succeeded'); +} + +function sdk() +{ + global $API_KEY; + $client = ZeroBounce::create(); + $client->initialize($API_KEY); + return $client; +} + +function creditBalance(ZeroBounce $client) +{ + return (int)$client->getCredits()->credits; +} + +// -- validation API, free ----------------------------------------------------- + +function validationFree() +{ + echo "Validation API (free calls)\n"; + $client = sdk(); + $before = creditBalance($client); + + test('getCredits returns a balance', function () use ($before) { + check($before >= 0 || $before === -1, 'credits was not a number'); + }); + + test('getApiUsage parses the documented date formats', function () use ($client) { + $end = new DateTime(); + $start = (new DateTime())->modify('-7 days'); + $usage = $client->getApiUsage($start, $end); + check($usage->total !== null, 'no total in response'); + }); + + test('validate on a sandbox address spends nothing', function () use ($client) { + $response = $client->validate(SANDBOX); + check($response->address === SANDBOX, 'address did not echo'); + check(is_string($response->status), 'no status'); + }); + + test('validate works over POST as well as GET', function () use ($client) { + // No billable add-on here: activity_data, verify_plus and domain_info + // are charged even on a sandbox address, so they live in the SPEND tier. + $options = new ZBValidateOptions(); + $options->usePost = true; + $options->timeout = 30; + $response = $client->validate(SANDBOX, null, $options); + check($response !== null, 'no response'); + }); + + test('LIVE: validateBatch accepts timeout as a string', function () use ($client) { + // Sent as a JSON number the API rejects the whole batch with the + // account-level "Invalid API Key or your account ran out of credits". + $options = new ZBValidateOptions(); + $options->timeout = 30; + $response = $client->validateBatch(array(SANDBOX), $options); + check(is_array($response->emailBatch), 'no email_batch'); + foreach ((array)$response->errors as $error) { + $address = is_array($error) && isset($error['email_address']) ? $error['email_address'] : ''; + $reason = is_array($error) && isset($error['error']) ? $error['error'] : ''; + check(strtolower((string)$address) !== 'all', 'account-level error: ' . $reason); + } + }); + + test('an unknown file id is a short, clean error', function () use ($client) { + $message = expectFailure(function () use ($client) { + $client->fileStatus(UNKNOWN_ID); + }, 'fileStatus'); + check(strlen($message) < 500, 'message too long: ' . strlen($message)); + check(strpos($message, 'listS3Files(); + } catch (\Throwable $e) { + // No S3 integration configured is an expected, documented failure. + check(strlen($e->getMessage()) < 500, 'message too long'); + } + }); + + test('LIVE: S3/SFTP jobs reject a payload without return_url before sending', function () use ($client) { + $message = expectFailure(function () use ($client) { + $client->emailFinderS3File(array('file_name' => 'f.csv', 'has_header_row' => true)); + }, 'emailFinderS3File without return_url'); + check(strpos($message, 'return_url') !== false, 'unexpected message: ' . $message); + }); + + test('LIVE: a complete S3 job payload is accepted by the API', function () use ($client) { + // Without an S3 integration this fails on the integration, not the payload. + try { + $client->emailFinderS3File(array( + 'file_name' => 'f.csv', + 'has_header_row' => true, + 'return_url' => CALLBACK, + )); + } catch (\Throwable $e) { + $message = redact($e->getMessage()); + check( + !preg_match('/return URL is not permitted|This field is required/i', $message), + 'payload rejected: ' . $message + ); + } + }); + + test('filters list', function () use ($client) { + $client->listFilters(); + }); + + test('LIVE: the Inbox Placement Tester accepts a Bearer key', function () use ($client) { + $providers = $client->getInboxPlacementProviders(); + check($providers !== null, 'no providers payload'); + }); + + test('inbox placement test types', function () use ($client) { + check($client->getInboxPlacementTestTypes() !== null, 'no test types'); + }); + + test('LIVE: inbox placement tests are listed from /tests, and the webhook reads back', + function () use ($client) { + // The listing is GET /tests (plural); /test is the single-test path + // and answers "Method \"GET\" not allowed". + $client->listInboxPlacementTests(null, null, null, 1, 10); + $client->getInboxPlacementWebhook(); + }); + + test('list evaluator result for an unknown id fails cleanly', function () use ($client) { + $message = expectFailure(function () use ($client) { + $client->getListEvaluatorResult(UNKNOWN_ID); + }, 'getListEvaluatorResult'); + check(strlen($message) < 500, 'message too long'); + }); + + test('LIVE: sendEvaluatedFile posts the file id as a form field', function () use ($client) { + // The endpoint takes file_id in the body, not in the path. + $message = expectFailure(function () use ($client) { + $client->sendEvaluatedFile(UNKNOWN_ID); + }, 'sendEvaluatedFile'); + check(strlen($message) < 500, 'message too long'); + check(stripos($message, 'not allowed') === false, 'wrong method or path: ' . $message); + }); + + test('the free calls did not change the credit balance', function () use ($before) { + $after = creditBalance(sdk()); + check($after === $before, 'credits went from ' . $before . ' to ' . $after); + }); +} + +// -- validation API, spends credits ------------------------------------------- + +function validationSpend() +{ + echo "\nValidation API (ZEROBOUNCE_LIVE_SPEND=1)\n"; + $client = sdk(); + + test('validate accepts every documented option', function () use ($client) { + // LIVE: activity_data, verify_plus and domain_info are billable add-ons + // and are charged even for a sandbox address, so this is not free. + $options = new ZBValidateOptions(); + $options->timeout = 30; + $options->activityData = true; + $options->verifyPlus = true; + $options->domainInfo = true; + $options->greylistProcessing = true; + $options->callbackUrl = CALLBACK; + check($client->validate(SANDBOX, null, $options) !== null, 'no response'); + }); + + test('scoreEmail', function () use ($client) { + check($client->scoreEmail(SANDBOX) !== null, 'no response'); + }); + + test('scoreEmail over POST', function () use ($client) { + check($client->scoreEmailPost(SANDBOX) !== null, 'no response'); + }); + + test('getActivity', function () use ($client) { + check($client->getActivity(SANDBOX) !== null, 'no response'); + }); + + test('email finder', function () use ($client) { + check($client->findEmail('example.com', 'John', null, null, 'Doe') !== null, 'no response'); + }); + + test('domain search', function () use ($client) { + check($client->findEmailFormat('example.com') !== null, 'no response'); + }); + + $fileId = null; + test('bulk validation round trip: upload, status, delete', function () use ($client, &$fileId) { + $csv = "email\nvalid@example.com\ninvalid@example.com\n"; + $sent = $client->sendFileStream($csv, 'zb-sdk-live.csv', 1, null, null, null, null, null, true); + check(!empty($sent->fileId), 'no file_id in the upload response'); + $fileId = $sent->fileId; + $status = $client->fileStatus($fileId); + check($status->fileId === $fileId, 'status did not echo the file id'); + }); + + test('the uploaded validation job is deleted again', function () use ($client, &$fileId) { + // LIVE: a just-accepted job answers "File cannot be found" for a short + // while, so the delete is retried rather than leaving the job behind. + $lastError = ''; + for ($attempt = 0; $attempt < 12; $attempt++) { + try { + $client->deleteFile($fileId); + return; + } catch (\Throwable $e) { + $lastError = redact($e->getMessage()); + sleep(10); + } + } + throw new \RuntimeException('could not delete ' . $fileId . ' after retrying: ' . $lastError); + }, $fileId !== null, 'upload did not produce a file id'); +} + +// -- validation API, reversible account changes ------------------------------- + +function validationChanges() +{ + echo "\nValidation API (ZEROBOUNCE_LIVE_CHANGES=1)\n"; + $client = sdk(); + $value = 'zb-sdk-live-' . time() . '.example.com'; + $added = false; + + try { + test('addFilter creates a reversible allow rule', function () use ($client, $value, &$added) { + $client->addFilter('allow', 'domain', $value); + $added = true; + }); + } finally { + test('the test filter is removed again', function () use ($client, $value) { + $client->deleteFilter('allow', 'domain', $value); + }, $added, 'filter was not created'); + } +} + +// -- Warmup API --------------------------------------------------------------- + +function warmup() +{ + global $WARMUP_KEY; + return new ZeroBounceWarmup($WARMUP_KEY); +} + +function warmupFree() +{ + global $WARMUP_KEY; + echo "\nWarmup API (reads)\n"; + $client = warmup(); + + $reads = array( + 'listSendingAccounts', 'listFromAddresses', 'getEngagementRules', 'getSeeds', + 'getSeedCount', 'getProgressToday', 'getSentToday', 'getEmailsPerDay', + ); + foreach ($reads as $name) { + test($name, function () use ($client, $name) { + // A documented empty success comes back as null, which is a result. + $client->{$name}(); + }); + } + + test('downloadSeeds returns CSV text', function () use ($client) { + $csv = $client->downloadSeeds(); + check(is_string($csv) && $csv !== '', 'no CSV body'); + check(strpos(ltrim($csv), '{') !== 0, 'got JSON instead of CSV'); + }); + + test('LIVE: checkDomainDmarc rejects a bare domain before sending', function () use ($client) { + $message = expectFailure(function () use ($client) { + $client->checkDomainDmarc('example.com'); + }, 'checkDomainDmarc with a domain'); + check(stripos($message, 'email address') !== false, 'unexpected message: ' . $message); + }); + + test('LIVE: checkDomainDmarc accepts an email address', function () use ($client) { + check($client->checkDomainDmarc('sender@example.com') !== null, 'no response'); + }); + + // The statistics endpoints need a from-address this team actually has; the + // API answers "Sending account ... was not found" for anything else. + $known = null; + try { + $accounts = $client->listSendingAccounts(); + $list = isset($accounts['data']) ? $accounts['data'] : $accounts; + if (is_array($list) && isset($list[0]) && is_array($list[0])) { + foreach (array('from_address', 'c_from', 'fromAddress') as $key) { + if (!empty($list[0][$key])) { + $known = $list[0][$key]; + break; + } + } + } + } catch (\Throwable $e) { + $known = null; + } + $has = $known !== null; + + test('statistics for a registered from-address', function () use ($client, $known) { + $client->getStatistics($known); + }, $has, 'this team has no sending account'); + + test('statistics per day over a short range', function () use ($client, $known) { + $client->getStatisticsPerDay($known, (new DateTime())->modify('-6 days'), new DateTime()); + }, $has, 'this team has no sending account'); + + test('detailed statistics and the CSV download for that address', function () use ($client, $known) { + $client->getStatisticsDetailed($known); + check(is_string($client->downloadStatistics($known)), 'no CSV body'); + }, $has, 'this team has no sending account'); + + test('from-address statistics', function () use ($client, $known) { + $client->getFromAddressStatistics($known); + }, $has, 'this team has no sending account'); + + test('an unknown Warmup resource fails with a status code', function () use ($client, $WARMUP_KEY) { + try { + $client->getCustomEngagement('no-such-address@example.com'); + } catch (\ZeroBounce\SDK\ZBApiException $e) { + check(is_int($e->getStatusCode()), 'no status code on the error'); + check(strpos($e->getMessage(), $WARMUP_KEY) === false, 'the key leaked into the message'); + } + }); +} + +function warmupChanges() +{ + echo "\nWarmup API (ZEROBOUNCE_LIVE_CHANGES=1)\n"; + $client = warmup(); + $address = 'zb-sdk-live-' . time() . '@example.com'; + $added = false; + + try { + test('a self-managed sending account can be added', function () use ($client, $address, &$added) { + $client->addSendingAccount($address, 'self_managed', 0, true); + $added = true; + }); + } finally { + test('the test sending account is removed again', function () use ($client, $address) { + $client->deleteSendingAccount($address); + }, $added, 'account was not created'); + } +} + +// -- main --------------------------------------------------------------------- + +echo "ZeroBounce live API suite\n"; +echo ' validation key: ' . ($API_KEY !== '' ? 'set' : 'MISSING') . "\n"; +echo ' warmup key: ' . ($WARMUP_KEY !== '' ? 'set' : 'MISSING') . "\n"; +echo ' flags: SPEND=' . ($SPEND ? 1 : 0) . ' CHANGES=' . ($CHANGES ? 1 : 0) . "\n\n"; + +if ($API_KEY !== '') { + validationFree(); +} +if ($API_KEY !== '' && $SPEND) { + validationSpend(); +} +if ($API_KEY !== '' && $CHANGES) { + validationChanges(); +} +if ($WARMUP_KEY !== '') { + warmupFree(); +} +if ($WARMUP_KEY !== '' && $CHANGES) { + warmupChanges(); +} + +echo "\n" . $passed . ' passed, ' . $failed . ' failed, ' . $skipped . " skipped\n"; +if (!empty($failures)) { + echo "\nFailures:\n"; + foreach ($failures as $line) { + echo ' - ' . $line . "\n"; + } +} +exit($failed ? 1 : 0); From 0cbb1913ff60296708d38764ef2fb97e48560f7c Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:23:25 +0400 Subject: [PATCH 17/20] feat: POST variants, date handling and request safety Parity with the Python SDK: - getCredits, getApiUsage, getActivity, scoreEmail, findEmail and findEmailFormat take $usePost. The API accepts the same parameters form-encoded, which keeps the key out of request logs. - getApiUsage formats its dates. A DateTime cast to a string became the whole locale-formatted stamp, which the API rejects and Cloudflare can block outright; a YYYY-MM-DD string is accepted too, anything else is refused before sending. - An API key with surrounding whitespace or control characters is rejected, because the transport would otherwise quote it unredacted in its own error. An empty key stays deferred, so the existing message naming initialize() is unchanged. - Every base URL must be https, and so must every URL a request is actually sent to. - A file or test id of "." or ".." is refused. Everything else, slashes included, is still percent-encoded into one path segment. - A regional base URL paired with the single global bulk host warns once per client: ZeroBounce documents no regional host for bulk files, the list evaluator, S3/SFTP or the Inbox Placement Tester. Co-Authored-By: Claude Opus 5 --- live/live_api.php | 10 ++ src/ZeroBounce.php | 222 +++++++++++++++++++----- src/ZeroBounceInboxPlacement.php | 5 +- src/ZeroBouncePriority3.php | 3 +- test/OfflineApiTest.php | 113 ++++++++++++ test/ZeroBounceHttpHeadersPhp85Test.php | 102 ++--------- 6 files changed, 322 insertions(+), 133 deletions(-) diff --git a/live/live_api.php b/live/live_api.php index bf2131e..7455cfb 100644 --- a/live/live_api.php +++ b/live/live_api.php @@ -163,6 +163,16 @@ function validationFree() } }); + test('LIVE: the documented endpoints answer the same over POST as over GET', function () use ($client) { + // The API takes api_key and the parameters form-encoded in the body on + // these endpoints, which keeps the key out of request logs. + $end = new DateTime(); + $start = (new DateTime())->modify('-7 days'); + check((int)$client->getCredits(true)->credits >= -1, 'no credits over POST'); + check($client->getApiUsage($start, $end, true)->total !== null, 'no usage total over POST'); + check($client->findEmailFormat('example.com', null, true) !== null, 'no format over POST'); + }); + test('an unknown file id is a short, clean error', function () use ($client) { $message = expectFailure(function () use ($client) { $client->fileStatus(UNKNOWN_ID); diff --git a/src/ZeroBounce.php b/src/ZeroBounce.php index 5f4d662..ff73e28 100644 --- a/src/ZeroBounce.php +++ b/src/ZeroBounce.php @@ -3,6 +3,7 @@ namespace ZeroBounce\SDK; use DateTime; +use DateTimeInterface; use Exception; /** @@ -61,6 +62,15 @@ protected function __construct() private $apiKey = null; + /** @var string|null "EU" or "US" when apiBaseUrl is a regional host. */ + private $region = null; + + /** @var bool The regional/bulk-host mismatch is reported once per client. */ + private $bulkRegionWarned = false; + + /** ZeroBounce documents no regional host for anything under this one. */ + const BulkHost = 'bulkapi.zerobounce.net'; + /** * @param string $apiKey */ @@ -70,14 +80,107 @@ public function initialize( $listEvaluatorBaseUrl = null, $inboxPlacementBaseUrl = null ) { - $this->apiKey = $apiKey; - $this->apiBaseUrl = ZBBaseUrl::getByValue($baseUrl); + $this->apiKey = self::checkedApiKey($apiKey); + $this->apiBaseUrl = self::requireHttpsUrl(ZBBaseUrl::getByValue($baseUrl), 'baseUrl'); if ($listEvaluatorBaseUrl !== null && $listEvaluatorBaseUrl !== '') { - $this->listEvaluatorBaseUrl = rtrim($listEvaluatorBaseUrl, '/'); + $this->listEvaluatorBaseUrl = rtrim( + self::requireHttpsUrl($listEvaluatorBaseUrl, 'listEvaluatorBaseUrl'), '/' + ); } if ($inboxPlacementBaseUrl !== null && $inboxPlacementBaseUrl !== '') { - $this->inboxPlacementBaseUrl = rtrim($inboxPlacementBaseUrl, '/'); + $this->inboxPlacementBaseUrl = rtrim( + self::requireHttpsUrl($inboxPlacementBaseUrl, 'inboxPlacementBaseUrl'), '/' + ); + } + $host = strtolower((string)parse_url($this->apiBaseUrl, PHP_URL_HOST)); + $regions = array('api-eu.zerobounce.net' => 'EU', 'api-us.zerobounce.net' => 'US'); + $this->region = isset($regions[$host]) ? $regions[$host] : null; + $this->bulkRegionWarned = false; + } + + /** + * The key, or a clear error. A key with surrounding whitespace or control + * characters would otherwise be quoted, unredacted, in a transport error. + * + * @param string $apiKey + * @return string + * @throws ZBException + */ + protected static function checkedApiKey($apiKey) + { + // An empty key stays deferred to checkValidApiKey(), which names the + // call that initializes the client. + if ($apiKey === null || trim((string)$apiKey) === '') { + return $apiKey; + } + if ($apiKey !== trim($apiKey) || preg_match('/[\x00-\x1f\x7f]/', $apiKey)) { + throw new ZBMissingParameterException( + 'api_key has surrounding whitespace or control characters' + ); + } + return $apiKey; + } + + /** + * @param string $url + * @param string $name + * @return string + * @throws ZBMissingParameterException + */ + protected static function requireHttpsUrl($url, $name) + { + if (stripos((string)$url, 'https://') !== 0) { + throw new ZBMissingParameterException($name . ' must be an https:// URL'); + } + return $url; + } + + /** + * One path segment, or a clear error. A segment of "." or ".." would walk + * the request up out of the endpoint it was meant for. + * + * @param string|int $value + * @param string $name + * @return string url-encoded + * @throws ZBMissingParameterException + */ + protected static function pathSegment($value, $name) + { + $trimmed = trim((string)$value); + if ($trimmed === '') { + throw new ZBMissingParameterException($name . ' is required'); + } + // Everything else, slashes included, is percent-encoded and stays + // inside the segment; "." and ".." would walk the request elsewhere. + if ($trimmed === '.' || $trimmed === '..') { + throw new ZBMissingParameterException($name . ' is invalid'); } + return rawurlencode($trimmed); + } + + /** + * The URL, after checking it is https and warning once when a regional + * base URL is paired with the single global bulk host. + * + * @param string $url + * @return string + * @throws ZBMissingParameterException + */ + protected function checkedUrl($url) + { + self::requireHttpsUrl($url, 'url'); + $host = strtolower((string)parse_url($url, PHP_URL_HOST)); + if (!$this->bulkRegionWarned && $this->region !== null && $host === self::BulkHost) { + $this->bulkRegionWarned = true; + trigger_error( + 'baseUrl is the ' . $this->region . ' API, but this request goes to ' . self::BulkHost + . ': ZeroBounce documents no regional host for bulk files, scoring files, email finder' + . ' and domain search files, the list evaluator, S3/SFTP or the Inbox Placement Tester.' + . ' Pass the matching base URLs to initialize() if ZeroBounce gave you regional ones.', + E_USER_WARNING + ); + } + return $url; } /** @@ -225,11 +328,11 @@ function ($email) { * @throws ZBMissingApiKeyException * @throws ZBException */ - public function getCredits() + public function getCredits($usePost = false) { $this->checkValidApiKey(); $response = new ZBGetCreditsResponse(); - $this->request($this->apiBaseUrl . "/getcredits?api_key=" . urlencode($this->apiKey), $response); + $this->getOrPostForm($this->apiBaseUrl . "/getcredits", array(), $response, $usePost); return $response; } @@ -240,7 +343,7 @@ public function getCredits() * @throws ZBMissingApiKeyException * @throws ZBException */ - public function getApiUsage($startDate, $endDate) + public function getApiUsage($startDate, $endDate, $usePost = false) { $this->checkValidApiKey(); @@ -248,31 +351,56 @@ public function getApiUsage($startDate, $endDate) if (!$endDate) throw new ZBMissingParameterException("endDate is required"); $response = new ZBApiUsageResponse(); - $format = "Y-m-d"; - $this->request($this->apiBaseUrl . "/getapiusage?api_key=" . urlencode($this->apiKey) - . "&start_date=" . $startDate->format($format) - . "&end_date=" . $endDate->format($format), - $response); + $this->getOrPostForm( + $this->apiBaseUrl . "/getapiusage", + array( + 'start_date' => self::apiDate($startDate, 'startDate'), + 'end_date' => self::apiDate($endDate, 'endDate'), + ), + $response, + $usePost + ); return $response; } + /** + * A date for the API: a DateTime, or a YYYY-MM-DD string. + * + * A DateTime cast to a string becomes the whole locale-formatted stamp, + * which the API rejects and Cloudflare can block outright. + * + * @param DateTimeInterface|string $value + * @param string $name + * @return string + * @throws ZBMissingParameterException + */ + protected static function apiDate($value, $name) + { + if ($value instanceof DateTimeInterface) { + return $value->format('Y-m-d'); + } + if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return $value; + } + throw new ZBMissingParameterException($name . ' must be a DateTime or a YYYY-MM-DD string'); + } + /** * @param string $email The email address to check * @return ZBActivityResponse * @throws ZBMissingApiKeyException * @throws ZBException */ - public function getActivity($email) + public function getActivity($email, $usePost = false) { $this->checkValidApiKey(); if (!$email) throw new ZBMissingParameterException("email is required"); $response = new ZBActivityResponse(); - $format = "Y-m-d"; - $this->request($this->apiBaseUrl . "/activity?api_key=" . urlencode($this->apiKey) - . "&email=" . urlencode($email), - $response); + $this->getOrPostForm( + $this->apiBaseUrl . "/activity", array('email' => $email), $response, $usePost + ); return $response; } @@ -283,15 +411,15 @@ public function getActivity($email) * @throws ZBMissingApiKeyException * @throws ZBException */ - public function scoreEmail($email) + public function scoreEmail($email, $usePost = false) { $this->checkValidApiKey(); if (!$email) throw new ZBMissingParameterException("email is required"); $response = new ZBScoreEmailResponse(); - $this->request($this->apiBaseUrl . "/scoring?api_key=" . urlencode($this->apiKey) - . "&email=" . urlencode($email), - $response); + $this->getOrPostForm( + $this->apiBaseUrl . "/scoring", array('email' => $email), $response, $usePost + ); return $response; } @@ -497,7 +625,7 @@ protected function json($url, $data, $response) $content = json_encode($data); $result = $this->executeHttp( 'POST', - $url, + $this->checkedUrl($url), array('Content-Type: application/json'), $content, 120 @@ -521,7 +649,7 @@ protected function formPost($url, $data, $response) $content = http_build_query($data); $result = $this->executeHttp( 'POST', - $url, + $this->checkedUrl($url), array('Content-Type: application/x-www-form-urlencoded'), $content, 120 @@ -532,6 +660,26 @@ protected function formPost($url, $data, $response) return (int)$result['status']; } + /** + * GET with query parameters, or the documented form-encoded POST with the + * same parameters. Both are accepted by every endpoint that takes this. + * + * @param string $url without a query string + * @param array $params without api_key + * @param ZBResponse $response + * @param bool $usePost + * @return int http statusCode + * @throws ZBException + */ + protected function getOrPostForm($url, array $params, $response, $usePost) + { + if ($usePost) { + return $this->formPost($url, $params, $response); + } + $params['api_key'] = $this->apiKey; + return $this->request($url . '?' . http_build_query($params), $response); + } + /** * this function is separated like this for easy mocking in the tests * @param string $url @@ -546,7 +694,7 @@ protected function curl($url, $fields, $files) $post_data = $this->build_data_files($boundary, $fields, $files); $result = $this->executeHttp( 'POST', - $url, + $this->checkedUrl($url), array( "Content-Type: multipart/form-data; boundary=" . $delimiter, "Content-Length: " . strlen($post_data), @@ -690,7 +838,7 @@ private function redactApiKey($message) */ protected function getBulkFileHttp($url) { - $result = $this->executeHttp('GET', $url, array(), null, 120); + $result = $this->executeHttp('GET', $this->checkedUrl($url), array(), null, 120); if ($result['body'] === '' && (int)$result['status'] === 0) { throw new ZBException('No response'); } @@ -912,7 +1060,7 @@ protected function executeHttp($method, $url, array $headers = array(), $body = protected function request($url, $response) { try { - $result = $this->executeHttp('GET', $url, array(), null, 120); + $result = $this->executeHttp('GET', $this->checkedUrl($url), array(), null, 120); $json = $this->readJsonBody($result); $response->Deserialize($json); return (int)$result['status']; @@ -1170,7 +1318,8 @@ public function guessFormat($domain, $firstName, $middleName, $lastName) * @throws ZBMissingApiKeyException * @throws ZBMissingParameterException */ - public function findEmail($domain, $firstName, $companyName = null, $middleName = null, $lastName = null) + public function findEmail( + $domain, $firstName, $companyName = null, $middleName = null, $lastName = null, $usePost = false) { $this->checkValidApiKey(); @@ -1183,10 +1332,7 @@ public function findEmail($domain, $firstName, $companyName = null, $middleName } $response = new ZBFindEmailResponse(); - $args = [ - 'api_key' => $this->apiKey, - 'first_name' => $firstName, - ]; + $args = ['first_name' => $firstName]; // The API doesn't accept both if ($domain) { @@ -1198,9 +1344,7 @@ public function findEmail($domain, $firstName, $companyName = null, $middleName if ($middleName) $args['middle_name'] = $middleName; if ($lastName) $args['last_name'] = $lastName; - $query = http_build_query($args); - - $this->request($this->apiBaseUrl . "/guessformat?$query", $response); + $this->getOrPostForm($this->apiBaseUrl . "/guessformat", $args, $response, $usePost); return $response; } @@ -1214,7 +1358,7 @@ public function findEmail($domain, $firstName, $companyName = null, $middleName * @throws ZBMissingApiKeyException * @throws ZBMissingParameterException */ - public function findEmailFormat($domain, $companyName = null) + public function findEmailFormat($domain, $companyName = null, $usePost = false) { $this->checkValidApiKey(); @@ -1223,9 +1367,7 @@ public function findEmailFormat($domain, $companyName = null) } $response = new ZBFindEmailFormatResponse(); - $args = [ - 'api_key' => $this->apiKey, - ]; + $args = []; // The API doesn't accept both if ($domain) { @@ -1234,9 +1376,7 @@ public function findEmailFormat($domain, $companyName = null) $args['company_name'] = $companyName; } - $query = http_build_query($args); - - $this->request($this->apiBaseUrl . "/guessformat?$query", $response); + $this->getOrPostForm($this->apiBaseUrl . "/guessformat", $args, $response, $usePost); return $response; } } diff --git a/src/ZeroBounceInboxPlacement.php b/src/ZeroBounceInboxPlacement.php index 5093750..6da9785 100644 --- a/src/ZeroBounceInboxPlacement.php +++ b/src/ZeroBounceInboxPlacement.php @@ -208,10 +208,7 @@ public function setInboxPlacementWebhook($webhookUrl) */ private function iptTestPath($testId) { - if ($testId === null || trim((string)$testId) === '') { - throw new ZBMissingParameterException("testId is required"); - } - return rawurlencode(trim((string)$testId)); + return self::pathSegment($testId, 'testId'); } /** diff --git a/src/ZeroBouncePriority3.php b/src/ZeroBouncePriority3.php index 81d7d3b..0912986 100644 --- a/src/ZeroBouncePriority3.php +++ b/src/ZeroBouncePriority3.php @@ -113,7 +113,8 @@ public function getListEvaluatorResult($fileId) { $this->checkValidApiKey(); $this->requireProvided($fileId, 'fileId is required'); - $url = $this->listEvaluatorBaseUrl . '/' . rawurlencode($fileId) . '/?api_key=' . urlencode($this->apiKey); + $url = $this->listEvaluatorBaseUrl . '/' . self::pathSegment($fileId, 'fileId') + . '/?api_key=' . urlencode($this->apiKey); $holder = new ZBRawJsonResponse(); $this->request($url, $holder); return $holder->data; diff --git a/test/OfflineApiTest.php b/test/OfflineApiTest.php index 72ea6b1..9718dbf 100644 --- a/test/OfflineApiTest.php +++ b/test/OfflineApiTest.php @@ -1216,6 +1216,119 @@ public function testSuccessFalseOnAnHttp200IsAnError() $this->assertSame(200, $error->getStatusCode()); } + public function testEveryDocumentedEndpointAlsoWorksOverPost() + { + $api = new FakeApi(); + $client = $this->client($api, 'post-key'); + $calls = array( + 'getcredits' => function () use ($client) { $client->getCredits(true); }, + 'getapiusage' => function () use ($client) { + $client->getApiUsage(new DateTime('2026-01-01'), new DateTime('2026-01-08'), true); + }, + 'activity' => function () use ($client) { $client->getActivity('a@b.com', true); }, + 'scoring' => function () use ($client) { $client->scoreEmail('a@b.com', true); }, + 'guessformat' => function () use ($client) { + $client->findEmailFormat('example.com', null, true); + }, + ); + foreach ($calls as $path => $call) { + $call(); + $request = $this->only($api); + $this->assertSame('POST', $request['method'], $path); + $this->assertStringEndsWith('/' . $path, $request['path']); + $this->assertSame('application/x-www-form-urlencoded', $request['content_type']); + $this->assertSame('post-key', $request['fields']['api_key']); + // The key belongs in the body, not in a query string that gets logged. + $this->assertSame('', (string)parse_url($request['url'], PHP_URL_QUERY)); + } + + // The same calls over GET put everything in the query string. + $client->getCredits(); + $this->assertSame('GET', $this->only($api)['method']); + } + + public function testFindEmailOverPostSendsTheSameFields() + { + $api = new FakeApi(); + $client = $this->client($api, 'post-key'); + $client->findEmail('example.com', 'Ada', null, null, 'Lovelace', true); + $request = $this->only($api); + $this->assertSame('POST', $request['method']); + $this->assertSame('Ada', $request['fields']['first_name']); + $this->assertSame('Lovelace', $request['fields']['last_name']); + $this->assertSame('example.com', $request['fields']['domain']); + } + + public function testGetApiUsageFormatsItsDatesForTheApi() + { + $api = new FakeApi(); + $client = $this->client($api, 'usage-key'); + // A DateTime cast to a string becomes the whole locale-formatted + // stamp, which the API rejects and Cloudflare can block outright. + $client->getApiUsage(new DateTime('2026-01-02 15:04:05'), '2026-01-09'); + $fields = $this->only($api)['fields']; + $this->assertSame('2026-01-02', $fields['start_date']); + $this->assertSame('2026-01-09', $fields['end_date']); + + $this->capture(function () use ($client) { + $client->getApiUsage('01/02/2026', '2026-01-09'); + }); + $this->assertSame(array(), $api->take()); + } + + public function testAKeyWithWhitespaceOrControlCharactersIsRejected() + { + foreach (array(' key ', "key\n", "key\tx", "key\x00") as $bad) { + $client = new OfflineClient(new FakeApi()); + $error = $this->capture(function () use ($client, $bad) { $client->initialize($bad); }); + $this->assertStringContainsString('whitespace', $error->getMessage()); + } + } + + public function testBaseUrlsMustBeHttps() + { + $client = new OfflineClient(new FakeApi()); + $error = $this->capture(function () use ($client) { + $client->initialize('key', null, 'http://example.com/listevaluator'); + }); + $this->assertStringContainsString('https', $error->getMessage()); + } + + public function testPathIdsCannotTraverseOutOfTheirEndpoint() + { + $api = new FakeApi(); + $client = $this->client($api, 'path-key'); + foreach (array('.', '..', ' .. ') as $bad) { + $this->capture(function () use ($client, $bad) { $client->getListEvaluatorResult($bad); }); + $this->capture(function () use ($client, $bad) { $client->getInboxPlacementTest($bad); }); + } + $this->assertSame(array(), $api->take()); + } + + public function testARegionalBaseUrlWarnsOnceWhenItMeetsTheGlobalBulkHost() + { + $api = new FakeApi(); + $client = new OfflineClient($api); + $client->initialize('eu-key', ZBBaseUrl::API_EU_URL); + + $warnings = array(); + set_error_handler(function ($level, $message) use (&$warnings) { + $warnings[] = $message; + return true; + }, E_USER_WARNING); + try { + $client->fileStatus('f1'); + $client->fileStatus('f2'); + $client->getCredits(); + } finally { + restore_error_handler(); + } + + $this->assertCount(1, $warnings, 'the mismatch is reported once per client'); + $this->assertStringContainsString('EU', $warnings[0]); + $this->assertStringContainsString('bulkapi.zerobounce.net', $warnings[0]); + } + private function client(FakeApi $api, $key) { $client = new OfflineClient($api); diff --git a/test/ZeroBounceHttpHeadersPhp85Test.php b/test/ZeroBounceHttpHeadersPhp85Test.php index e585b90..e834c39 100644 --- a/test/ZeroBounceHttpHeadersPhp85Test.php +++ b/test/ZeroBounceHttpHeadersPhp85Test.php @@ -64,93 +64,21 @@ public function testExecuteHttpCollectsHeadersAndStatusFromCurl(): void $this->assertStringContainsString('CURLOPT_FOLLOWLOCATION => false', $execute); } - public function testGetBulkFileHttpReadsAFileUrlWithoutEmittingADeprecation(): void + public function testTheSdkRefusesToSendTheKeyOverPlainHttp(): void { - $tmp = tempnam(sys_get_temp_dir(), 'zb_'); - if ($tmp === false) { - $this->markTestSkipped('Could not create temp file'); - } - $this->assertNotFalse(file_put_contents($tmp, "col1,col2\na@b.com,1\n")); - - try { - $bootstrap = realpath(dirname(__DIR__) . '/vendor/autoload.php'); - $this->assertNotFalse($bootstrap); - $probePath = sys_get_temp_dir() . '/zb_http_deprecation_probe_' . uniqid('', true) . '.php'; - $this->assertNotFalse( - file_put_contents($probePath, self::getBulkFileHttpProbeScript($bootstrap, $tmp)) - ); - - $cmd = [\PHP_BINARY, '-d', 'display_errors=1', '-d', 'error_reporting=' . (string) \E_ALL, $probePath]; - $proc = proc_open( - $cmd, - [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], - $pipes, - null, - null, - ['bypass_shell' => true] - ); - $this->assertNotFalse($proc); - fclose($pipes[0]); - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $code = proc_close($proc); - @unlink($probePath); - - $combined = $stdout . $stderr; - $this->assertSame(0, $code, 'Probe script exit code. Output: ' . $combined); - $this->assertStringNotContainsStringIgnoringCase( - 'deprecated', - $combined, - 'Expected no deprecation. Output: ' . $combined - ); - $this->assertStringNotContainsString('http_response_header', $combined); - } finally { - @unlink($tmp); - } - } - - /** - * Standalone script so engine deprecation output is visible to the parent - * process on stderr. - * - * @param non-empty-string $bootstrap - */ - private static function getBulkFileHttpProbeScript(string $bootstrap, string $dataFilePath): string - { - $bootstrapExport = var_export($bootstrap, true); - $fileExport = var_export($dataFilePath, true); - - return <<getBulkFileHttp(\$url); - } -} - -\$url = 'file://' . str_replace('\\\\', '/', {$fileExport}); -\$out = (new ZbGetBulkProbe())->run(\$url); -if (\$out['body'] !== "col1,col2\\na@b.com,1\\n") { - fwrite(STDERR, "unexpected body: " . var_export(\$out['body'], true) . "\\n"); - exit(1); -} -if (!is_array(\$out['headers'])) { - fwrite(STDERR, "headers were not an array\\n"); - exit(1); -} -exit(0); - -PHP; + $probe = new class extends ZeroBounce { + public function __construct() + { + parent::__construct(); + } + + public function call(string $url): string + { + return $this->checkedUrl($url); + } + }; + + $this->expectException(\ZeroBounce\SDK\ZBMissingParameterException::class); + $probe->call('http://api.zerobounce.net/v2/getcredits'); } } From ae005b3725b65e8d27c78abea0a5b677e7cb7719 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:25:49 +0400 Subject: [PATCH 18/20] feat: typed list evaluator result and opt-in streaming downloads getListEvaluatorResult returns ZBListEvaluatorResponse: the documented fields are named, and everything the API sent is still on ->data, so a field this version does not name yet is not lost. ZBGetFileOptions gains $stream. With it, a result file is written to disk in chunks rather than held in memory: the bytes land in a temporary file beside the download path and are moved into place only when the transfer completes, so a failed download leaves nothing behind. The first 64 KB are kept in memory so an error body is still recognised and reported as a short message rather than saved as a result file. Co-Authored-By: Claude Opus 5 --- src/ZBGetFileOptions.php | 9 +++ src/ZBListEvaluatorResponse.php | 59 +++++++++++++++ src/ZeroBounce.php | 127 ++++++++++++++++++++++++++++++++ src/ZeroBouncePriority3.php | 15 +++- test/OfflineApiTest.php | 47 +++++++++++- test/OfflineHttp.php | 13 ++++ 6 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 src/ZBListEvaluatorResponse.php diff --git a/src/ZBGetFileOptions.php b/src/ZBGetFileOptions.php index 2907b27..c85827f 100644 --- a/src/ZBGetFileOptions.php +++ b/src/ZBGetFileOptions.php @@ -17,4 +17,13 @@ class ZBGetFileOptions * @var bool|null When not null, sent as activity_data (validation bulk only). */ public $activityData; + + /** + * @var bool Write the result to disk in chunks instead of holding it in + * memory. The download lands in a temporary file beside $downloadPath and + * is moved into place only once it is complete, so a failed download + * leaves nothing behind. Off by default, because the non-streaming path + * returns the body the SDK already has. + */ + public $stream = false; } diff --git a/src/ZBListEvaluatorResponse.php b/src/ZBListEvaluatorResponse.php new file mode 100644 index 0000000..727247a --- /dev/null +++ b/src/ZBListEvaluatorResponse.php @@ -0,0 +1,59 @@ + Everything the API sent, including fields this class does not name. */ + public $data = array(); + + public function Deserialize($json) + { + parent::Deserialize($json); + $decoded = is_array($json) ? $json : json_decode((string)$json, true); + $this->data = is_array($decoded) ? $decoded : array(); + } + + public function __toString() + { + return "ZBListEvaluatorResponse{fileId=" . $this->stringField($this->fileId) + . ", status=" . $this->stringField($this->status) + . ", progress=" . $this->stringField($this->progress) . "}"; + } +} diff --git a/src/ZeroBounce.php b/src/ZeroBounce.php index ff73e28..f1c3888 100644 --- a/src/ZeroBounce.php +++ b/src/ZeroBounce.php @@ -812,6 +812,9 @@ private function _getFile($scoring, $fileId, $downloadPath, $options = null) $path = ($scoring ? '/scoring' : '') . '/getfile'; $url = self::BulkApiBaseUrl . $path . '?' . http_build_query($query); + if ($options instanceof ZBGetFileOptions && $options->stream) { + return $this->streamBulkDownload($url, $downloadPath); + } return $this->saveBulkDownload($this->getBulkFileHttp($url), $downloadPath); } catch (Exception $e) { throw new ZBException($this->redactApiKey($e->getMessage())); @@ -1199,6 +1202,130 @@ private function saveBulkDownload(array $result, $downloadPath) return $response; } + /** + * Stream a GET to $handle, keeping the first DownloadSniffBytes in memory + * so the caller can tell a result file from an error body. Separated like + * executeHttp so tests never touch the network. + * + * @param string $url + * @param resource $handle open for writing + * @return array{status: int, headers: string[], head: string, error: string} + * @throws ZBException + */ + protected function streamHttpToHandle($url, $handle) + { + $head = ''; + $headers = array(); + $sniffing = true; + $curl = curl_init(); + curl_setopt_array($curl, array( + CURLOPT_URL => $this->checkedUrl($url), + CURLOPT_TIMEOUT => 0, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_MAXREDIRS => 0, + CURLOPT_HEADERFUNCTION => function ($curlHandle, $header) use (&$headers) { + $line = trim($header); + if ($line !== '') { + $headers[] = $line; + } + return strlen($header); + }, + CURLOPT_WRITEFUNCTION => function ($curlHandle, $chunk) use (&$head, &$sniffing, $handle) { + if ($sniffing) { + $head .= $chunk; + if (strlen($head) >= self::DownloadSniffBytes) { + $sniffing = false; + fwrite($handle, $head); + } + return strlen($chunk); + } + fwrite($handle, $chunk); + return strlen($chunk); + }, + )); + $ok = curl_exec($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $error = curl_error($curl); + curl_close($curl); + if ($sniffing) { + // The whole body fit inside the sniff window and was never written. + fwrite($handle, $head); + } + return array( + 'status' => $status, + 'headers' => $headers, + 'head' => $head, + 'error' => $ok === false ? ($error === '' ? 'The download failed.' : $error) : '', + ); + } + + /** Sniff this much before deciding whether a download is really an error body. */ + const DownloadSniffBytes = 65536; + + /** An error body is short; reading more than this to quote it would defeat streaming. */ + const DownloadMaxErrorBytes = 2097152; + + /** + * GET a result file and write it to $downloadPath in chunks. + * + * The bytes go to a temporary file beside $downloadPath and are moved into + * place only once the transfer finishes, so a large file never has to fit + * in memory and a failed download leaves nothing at $downloadPath. + * + * @param string $url + * @param string $downloadPath + * @return ZBGetFileResponse + * @throws ZBException + */ + protected function streamBulkDownload($url, $downloadPath) + { + $folder = dirname($downloadPath); + if ($folder === '' || $folder === '.') { + $folder = '.'; + } elseif (!file_exists($folder)) { + mkdir($folder, 0777, true); + } + $partial = tempnam($folder, '.zb-download-'); + if ($partial === false) { + throw new ZBException('Could not write file'); + } + $handle = fopen($partial, 'wb'); + if ($handle === false) { + @unlink($partial); + throw new ZBException('Could not write file'); + } + + $transfer = $this->streamHttpToHandle($url, $handle); + fclose($handle); + $head = (string)$transfer['head']; + $headers = (array)$transfer['headers']; + $status = (int)$transfer['status']; + $error = (string)$transfer['error']; + + try { + if ($error !== '') { + // Rebuilt rather than wrapped: the original holds the URL. + throw new ZBException($this->redactApiKey($error)); + } + $contentType = self::parseContentTypeFromHeaders($headers); + $sample = substr($head, 0, self::DownloadMaxErrorBytes); + if (trim($sample) === '' || $status > 299 || ZBHttpError::isHtml($sample, $headers) + || self::shouldTreatGetFileBodyAsError($sample, $contentType)) { + throw new ZBException($this->redactApiKey(ZBHttpError::message($status, $sample, $headers))); + } + if (!rename($partial, $downloadPath)) { + throw new ZBException('Could not write file'); + } + } catch (Exception $e) { + @unlink($partial); + throw $e; + } + + $response = new ZBGetFileResponse(); + $response->localFilePath = $downloadPath; + return $response; + } + /** * @return array */ diff --git a/src/ZeroBouncePriority3.php b/src/ZeroBouncePriority3.php index 0912986..e0afec5 100644 --- a/src/ZeroBouncePriority3.php +++ b/src/ZeroBouncePriority3.php @@ -107,7 +107,14 @@ public function sendListEvaluatorFileStream($streamContent, $fileName, $emailAdd } /** - * @return array decoded JSON + * The evaluation's progress and results. GET /listevaluator/{file_id}/ + * + * Everything the API sent is also on ->data, including any field this + * version of the SDK does not name yet. + * + * @param string $fileId + * @return ZBListEvaluatorResponse + * @throws ZBException */ public function getListEvaluatorResult($fileId) { @@ -115,9 +122,9 @@ public function getListEvaluatorResult($fileId) $this->requireProvided($fileId, 'fileId is required'); $url = $this->listEvaluatorBaseUrl . '/' . self::pathSegment($fileId, 'fileId') . '/?api_key=' . urlencode($this->apiKey); - $holder = new ZBRawJsonResponse(); - $this->request($url, $holder); - return $holder->data; + $response = new ZBListEvaluatorResponse(); + $this->request($url, $response); + return $response; } /** diff --git a/test/OfflineApiTest.php b/test/OfflineApiTest.php index 9718dbf..4ef5d58 100644 --- a/test/OfflineApiTest.php +++ b/test/OfflineApiTest.php @@ -432,7 +432,7 @@ public function testPriority3EndpointsAndZeroColumn() $api->clear(); $raw = $client->getListEvaluatorResult('a/b'); - $this->assertSame('p3-key', $raw['echo_api_key']); + $this->assertSame('p3-key', $raw->data['echo_api_key']); $result = $this->only($api); $this->assertSame('/v2/listevaluator/a%2Fb/', $result['path']); $this->assertSame('p3-key', $result['fields']['api_key']); @@ -1329,6 +1329,51 @@ public function testARegionalBaseUrlWarnsOnceWhenItMeetsTheGlobalBulkHost() $this->assertStringContainsString('bulkapi.zerobounce.net', $warnings[0]); } + public function testAStreamedDownloadLandsAtTheDownloadPath() + { + $api = new FakeApi(); + $client = $this->client($api, 'stream-key'); + $target = sys_get_temp_dir() . '/zb-stream-' . uniqid('', true) . '/result.csv'; + $options = new ZBGetFileOptions(); + $options->stream = true; + + $response = $client->getFile('f1', $target, $options); + try { + $this->assertSame($target, $response->localFilePath); + $this->assertSame("email\nstream-key@example.com\n", file_get_contents($target)); + $this->assertSame('GET', $this->only($api)['method']); + } finally { + @unlink($target); + @rmdir(dirname($target)); + } + } + + public function testAStreamedDownloadThatIsReallyAnErrorLeavesNothingBehind() + { + $api = new FakeApi(); + $client = $this->client($api, 'html-403-stream'); + $folder = sys_get_temp_dir() . '/zb-stream-' . uniqid('', true); + mkdir($folder); + $target = $folder . '/result.csv'; + $options = new ZBGetFileOptions(); + $options->stream = true; + + try { + $error = $this->capture(function () use ($client, $target, $options) { + $client->getFile('f1', $target, $options); + }); + $this->assertStringContainsString('HTML error page', $error->getMessage()); + $this->assertFileDoesNotExist($target); + // Not even the half-written temporary file survives. + $this->assertSame(array('.', '..'), array_values(scandir($folder))); + } finally { + foreach (glob($folder . '/*') ?: array() as $leftover) { + @unlink($leftover); + } + @rmdir($folder); + } + } + private function client(FakeApi $api, $key) { $client = new OfflineClient($api); diff --git a/test/OfflineHttp.php b/test/OfflineHttp.php index b31a9b9..5924a74 100644 --- a/test/OfflineHttp.php +++ b/test/OfflineHttp.php @@ -330,6 +330,19 @@ protected function executeHttp($method, $url, array $headers = array(), $body = { return $this->fake->handle($method, $url, $headers, $body, $timeout); } + + protected function streamHttpToHandle($url, $handle) + { + $result = $this->fake->handle('GET', $url, array(), null, 120); + $body = (string)$result['body']; + fwrite($handle, $body); + return array( + 'status' => (int)$result['status'], + 'headers' => $result['headers'], + 'head' => substr($body, 0, 65536), + 'error' => '', + ); + } } class DirectClient extends ZeroBounce From 347ea696a7cab227e56a4c962aaf09dc5a6177be Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:30:09 +0400 Subject: [PATCH 19/20] docs: cover the Warmup API, the live suite and the new options README gains a Warmup API section, the opt-in live suite and its two flags, the streaming download option, and the $usePost argument. AGENTS.md records where the live API and the docs disagree, the rule that the API wins and the call site says so, and the release rule that the live suite is run by hand before every release. CHANGELOG lists every user-visible change under [Unreleased]. composer: `lint` covers live/, the phpunit floor is the current patch release, and composer.lock is refreshed. `composer audit` reports no advisories. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 37 +++++++++++++++++----- CHANGELOG.md | 20 ++++++++++++ README.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++- composer.json | 2 +- composer.lock | 2 +- 5 files changed, 136 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed12ca8..e35bb3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,32 +4,52 @@ Guidance for AI coding agents working in this repository. ## Project -`zero-bounce-php-sdk-setup` is the ZeroBounce PHP SDK, package `zero-bounce/sdk` on Packagist. Callers use `ZeroBounce::create()`, then `initialize($apiKey)` or `initialize($apiKey, $baseUrl)`. `ZeroBounce::Instance()` is a process-wide singleton. Failures throw `ZBException`. HTTP goes through cURL and `file_get_contents` behind `executeHttp`. +`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`. Every HTTP call goes through cURL behind `executeHttp`. + +`src/ZeroBounceWarmup.php` is a separate client for the Warmup API, which is a different product with its own host and its own team-scoped key. ## Layout - `src/ZeroBounce.php`: validation, bulk file, credits, activity, scoring. `executeHttp` is the HTTP call. - `src/ZeroBouncePriority3.php`: bulk email finder, domain search, list evaluator, filters, S3. It is a trait on `ZeroBounce`. - `src/ZeroBounceInboxPlacement.php`: Inbox Placement Tester. Also a trait on `ZeroBounce`. +- `src/ZeroBounceWarmup.php`: the Warmup API client. Not a trait: its own class, its own key, its own `executeHttp`. - `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. +- `test/OfflineWarmupTest.php`: the same for the Warmup client. +- `live/live_api.php`: the opt-in live suite. `phpunit.xml` only includes `test/`, so `composer test` and CI never run it. - `composer.json`: the only version field, `"version": "x.y.z"`. `composer.lock` lists dependencies, not this package's version. There is no runtime version constant and no User-Agent. ## Commands ```bash composer install -composer lint # php -l on src and test -composer test # phpunit +composer lint # php -l on src, test and live +composer test # phpunit +composer audit # check dependencies against the security advisories ``` -`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. +`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`. + +The live suite is opt-in and never runs in CI: + +```bash +export ZEROBOUNCE_API_KEY=... # validation API +export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API +composer run test:live # free calls only +ZEROBOUNCE_LIVE_SPEND=1 composer run test:live # also spends credits +ZEROBOUNCE_LIVE_CHANGES=1 composer run test:live # also changes the account, undone in cleanup +``` + +Keys come from the environment only. Never write one into a file, a commit, a log, a fixture or a message. ## Conventions - **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 bulk endpoints live under https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, not the main API reference. -- **The Inbox Placement Tester takes the API key as the `api_key` query parameter and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`. +- **Where the live API and the docs disagree, follow the API and say so in a comment at the call site.** The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer`, not `api_key`; its listing is `GET /tests`, not `/test`; `send-evaluated-file` takes `file_id` as a form field, not a file upload; the S3 and SFTP jobs require `return_url` and `file_name`; the Warmup `check-domain-dmarc` takes an email address, not a domain; and a failed SMTP login in `test-smtp-configuration` comes back as an HTTP 500. +- **The Inbox Placement Tester takes the API key as an `Authorization: Bearer` header and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`, and its list errors are top-level JSON arrays of strings. +- **`{"success": false}` on an HTTP 200 is a failure.** The API answers an unknown bulk file id that way. `ZBHttpError::throwIfSuccessIsFalse` turns it into a `ZBApiException` carrying the status. - **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. @@ -42,9 +62,10 @@ composer test # phpunit - **`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. +- **`executeHttp` does not follow redirects and only talks https.** `CURLOPT_FOLLOWLOCATION` is false and `CURLOPT_MAXREDIRS` is 0; a 3xx is an error. Do not reintroduce the `http://` stream wrapper: it reports its status only through PHP's predefined response-header variable, which the engine shows to the function that called `file_get_contents` and to no one else, so the status came back 0 and an error looked like a result. 8.5 deprecates reading it at all. +- **Errors never carry the key.** `redactApiKey` masks it in every message, and `ZBApiException::getStatusCode()` carries the HTTP status so callers can tell a Cloudflare 403 from an API 400/404. - **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/`. +- Match the surrounding style. The library still runs on PHP 7.0: no scalar property types, `fn()`, or `str_contains` in `src/`. Do not use an implicitly nullable parameter (`array $x = null`); 8.4 deprecates it and `?array` needs 7.1. ## Changelog @@ -59,6 +80,8 @@ composer test # phpunit Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. +**Run the live suite by hand before every release.** The offline suite proves the SDK sends what we think it sends; only the live suite proves the API accepts it. Run it with real keys, including `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`, and do not release on a failure. + 1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links at the bottom of `CHANGELOG.md`. 2. Set the same version in `composer.json` (`"version"`). The publish workflow fails if it differs from the tag. That is the only version field. 3. `composer lint` and `composer test`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4968b98..73f202d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ZBValidateResponse` carries `domainWebsiteExists`, `domainRegistrantCompanyName`, and `message`. `message` is read from the API's `Message` key, returned while greylist processing is still running. - Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `getInboxPlacementProviders`, `getInboxPlacementTestTypes`, `createInboxPlacementTest`, `getInboxPlacementTest`, `listInboxPlacementTests`, `deleteInboxPlacementTest`, `getInboxPlacementWebhook`, `setInboxPlacementWebhook`. - `initialize` takes optional third and fourth arguments to override the list evaluator and Inbox Placement Tester base URLs. +- `ZeroBounceWarmup`, a client for the Warmup API. It is a separate product with its own host (`https://warmup-api.zerobounce.net`), its own team-scoped key sent in an `API-Key` header, and all 26 documented endpoints, including the seed-list and statistics CSV downloads. +- `getCredits`, `getApiUsage`, `getActivity`, `scoreEmail`, `findEmail` and `findEmailFormat` take a trailing `$usePost`. The API accepts the same parameters form-encoded, which keeps the key out of request logs. +- `getApiUsage` accepts a `YYYY-MM-DD` string as well as a `DateTime`. +- `ZBGetFileOptions::$stream` writes a result file to disk in chunks instead of holding it in memory. The bytes land in a temporary file beside the download path and move into place only once the transfer finishes. +- `ZBApiException::getStatusCode()` returns the HTTP status of a failed response, so callers can tell a Cloudflare 403 from an API 400/404. +- A regional `baseUrl` paired with the global bulk host raises an `E_USER_WARNING` once per client: ZeroBounce documents no regional host for bulk files, the list evaluator, S3/SFTP or the Inbox Placement Tester. +- An opt-in live API suite (`composer run test:live`). It is excluded from `composer test` and from CI, needs keys in the environment, and only spends credits or changes the account behind `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`. ### Changed @@ -30,9 +37,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - API requests time out after 120 seconds. - Each `ZeroBounce` instance keeps its own API base URL. +- `validateBatch` sends `timeout` as a JSON string. Sent as a number, the live API rejects the whole batch with an account-level error. +- The Inbox Placement Tester authenticates with an `Authorization: Bearer` header instead of an `api_key` parameter, and `listInboxPlacementTests` calls `GET /tests`. `GET /test` answers "Method \"GET\" not allowed". +- `sendEvaluatedFile($fileId)` posts the list evaluator's `file_id` as a form field. The endpoint rejects a file upload with "file_id: This field is required". `sendEvaluatedFileStream` is gone. +- The S3 and SFTP/FTP jobs require `return_url`, and `file_name` is required on all of them. The API answers "This field is required" otherwise. +- `getListEvaluatorResult` returns a `ZBListEvaluatorResponse` instead of a plain array. Everything the API sent is still available on `->data`. +- Every HTTP call goes through cURL. The `http://` stream wrapper is no longer used. ### Fixed +- The HTTP status of a `GET` is read correctly. It was always recorded as 0, because the stream wrapper reports it only through PHP's predefined response-header variable, which the engine makes visible to the function that called `file_get_contents` and to no one else; an error status therefore never reached the response check and a 404 from the list evaluator came back as a successful result. +- `{"success": false}` on an HTTP 200 raises instead of being returned as a result. The API answers an unknown bulk file id that way. +- `getApiUsage` formats its dates. A `DateTime` was interpolated whole, so the request carried a locale-formatted timestamp that the API rejects and Cloudflare can block. - `validateS3File`, `scoreS3File`, and `exportS3File` reject an empty API key before a request is sent. - An error body whose JSON carries a `detail` string (the Inbox Placement Tester, Django REST framework) raises with that text. @@ -47,6 +63,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Every base URL, and every URL a request is sent to, must be `https://`. +- An API key with surrounding whitespace or control characters is rejected, because the transport would otherwise quote it unredacted in its own error. +- A file or test id of `.` or `..` is rejected rather than walking the request out of its endpoint. +- The Warmup SMTP test masks the username and password out of every error it raises. ## [2.1.10] - 2026-06-18 diff --git a/README.md b/README.md index 8a8f84b..f312409 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,16 @@ $options->activityData = true; $response = ZeroBounce::Instance()->getFile($fileId, $downloadPath, $options); ``` -If the API returns a non-success HTTP status or a JSON error body (including some HTTP 200 responses with `success: false`), the SDK throws `ZBException`. To inspect raw bodies yourself, use `ZeroBounce::getFileJsonIndicatesError($jsonString)`. +Set `$options->stream = true` for a large result file. The download is written to disk in chunks instead of being held in memory: the bytes go to a temporary file beside `$downloadPath` and move into place only once the transfer finishes, so a failed download leaves nothing behind. + +```php +$options = new ZBGetFileOptions(); +$options->stream = true; + +$response = ZeroBounce::Instance()->getFile($fileId, $downloadPath, $options); +``` + +If the API returns a non-success HTTP status or a JSON error body (including some HTTP 200 responses with `success: false`), the SDK throws `ZBApiException`, whose `getStatusCode()` gives the HTTP status. To inspect raw bodies yourself, use `ZeroBounce::getFileJsonIndicatesError($jsonString)`. - Deletes the file that was submitted using scoring sendfile API. File can be deleted only when its status is _`Complete`_ ```php @@ -367,12 +376,65 @@ $response = ZeroBounce::Instance()->findEmail( $email = $response->email; ``` +`getCredits`, `getApiUsage`, `getActivity`, `scoreEmail`, `findEmail` and `findEmailFormat` take a trailing `$usePost`. The API accepts the same parameters form-encoded, which keeps the key out of request logs: + +```php +$response = ZeroBounce::Instance()->getCredits(true); +``` + Guess the format of email addresses for a domain or company. Must provide either `domain` or `companyName`. If both are provided, only `domain` is used. If necessary, `domain` can be `null`. ```php $response = ZeroBounce::Instance()->findEmailFormat($domain, $companyName); $email = $response->format; ``` +## Warmup API + +The Warmup API is a separate product. It has its own host, its own team-scoped key, and its own client; your validation key will not work with it. + +```php +use ZeroBounce\SDK\ZeroBounceWarmup; + +$warmup = new ZeroBounceWarmup(""); + +$accounts = $warmup->listSendingAccounts(); +$progress = $warmup->getProgressToday(); +$csv = $warmup->downloadSeeds(); // CSV text, columns seed,provider +``` + +Add a from-address you run yourself, with one of the five standard engagement rules (0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect): + +```php +$warmup->addSendingAccount("sender@example.com", "self_managed", 0); +``` + +Or have ZeroBounce provision the mailbox. A 200 means the request was accepted; provisioning happens afterwards. + +```php +$warmup->addSendingAccount( + "sender@example.com", + "zb_managed", + null, + null, + array("types_of_emails" => array("newsletter", "marketing")), + array("timezone_offset" => -5, "warmup_start_date" => new DateTime("2026-03-04")) +); +``` + +A custom engagement rule can be changed only once every seven days; inside that window the API answers 429. + +```php +$warmup->createCustomEngagement("sender@example.com", 20, 20, 35); // reply, important, click % +``` + +`checkDomainDmarc` takes an **email address**, not a domain — the live API rejects a bare domain even though the docs describe one: + +```php +$dmarc = $warmup->checkDomainDmarc("sender@example.com"); +``` + +Every failure is a `ZBApiException`, whose `getStatusCode()` gives the HTTP status. Nothing is stored by `testSmtpConfiguration`, and the credentials you pass it are masked out of any error it raises. + ## Development ### Run tests with Docker @@ -400,6 +462,27 @@ Run tests ./vendor/bin/phpunit test ``` +### Live API suite + +`phpunit.xml` only includes `test/`, so neither `composer test` nor CI ever touches the network. The live suite is opt-in and needs real keys, which come from the environment and are never written anywhere: + +```bash +export ZEROBOUNCE_API_KEY=... # validation API +export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API +composer run test:live +``` + +By default it only makes free calls: sandbox addresses, reads, and requests that are expected to fail but still prove the API accepts the payload. It checks that the credit balance did not move. Two flags opt in to more: + +| Flag | What it adds | +| --- | --- | +| `ZEROBOUNCE_LIVE_SPEND=1` | billable calls (scoring, activity, email finder) and a bulk round trip that deletes its own job | +| `ZEROBOUNCE_LIVE_CHANGES=1` | reversible account changes — a test filter and a self-managed Warmup sending account — each undone in cleanup even when a test fails | + +It never creates an Inbox Placement test, sets the Inbox Placement webhook, or starts ZB-managed Warmup onboarding, because the API cannot undo any of those. + +Run it by hand before every release: the offline suite proves the SDK sends what we think it sends, and only the live suite proves the API accepts it. + ## Publish 1. Bump `version` in `composer.json`, commit, tag (`vX.Y.Z`), push tag. diff --git a/composer.json b/composer.json index 079e679..cd5841c 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "ext-curl": "*" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.5.63" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index dce5474..4c72bc2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6489d456e0ca1a1d2b2209357edf35f6", + "content-hash": "4a0aed1fd6fd8aa6e9859fce6565e42b", "packages": [], "packages-dev": [ { From 8960888f87b25c785d03cedf9c9b24b2330e11bd Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:08:40 +0400 Subject: [PATCH 20/20] fix: four api-usage counters were always null, and two were never read Found by auditing every SDK's api-usage model against the keys the live API actually returns, after the same class of bug turned up in Rust and iOS. ZBResponse::Deserialize converts a snake_case key to camelCase and skips any key with no matching property. Four counters convert to names that are not the ones declared: sub_status_antispam_system -> subStatusAntispamSystem sub_status_greylisted -> subStatusGreylisted sub_status_unroutable_ip_address -> subStatusUnroutableIpAddress sub_status_mail_server_did_not_respond -> subStatusMailServerDidNotRespond against properties spelled $subStatusAntiSpamSystem, $subStatusGreyListed, $subStatusUnRoutableIpAddress and $subStatusMailServerDidNotResponse. All four were therefore skipped and stayed null on every call. Renaming the properties would break callers, so ZBApiUsageResponse overrides getClassKey for those four instead. sub_status_leading_period_removed and sub_status_alias_address are returned by the API and had no property at all; both are now declared and read. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +++ src/ZBApiUsageResponse.php | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f202d..6070c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Four `ZBApiUsageResponse` counters were never populated and stayed `null` for every caller. The base class converts `snake_case` to `camelCase`, which turns `sub_status_antispam_system`, `sub_status_greylisted`, `sub_status_unroutable_ip_address` and `sub_status_mail_server_did_not_respond` into names that do not match the declared properties (`$subStatusAntiSpamSystem`, `$subStatusGreyListed`, `$subStatusUnRoutableIpAddress`, `$subStatusMailServerDidNotResponse`). The four are now mapped explicitly; the property names are unchanged, so no caller breaks. +- `ZBApiUsageResponse` gained `$subStatusLeadingPeriodRemoved` and `$subStatusAliasAddress`, two counters the API returns that were silently discarded. + - The HTTP status of a `GET` is read correctly. It was always recorded as 0, because the stream wrapper reports it only through PHP's predefined response-header variable, which the engine makes visible to the function that called `file_get_contents` and to no one else; an error status therefore never reached the response check and a 404 from the list evaluator came back as a successful result. - `{"success": false}` on an HTTP 200 raises instead of being returned as a result. The API answers an unknown bulk file id that way. - `getApiUsage` formats its dates. A `DateTime` was interpolated whole, so the request carried a locale-formatted timestamp that the API rejects and Cloudflare can block. diff --git a/src/ZBApiUsageResponse.php b/src/ZBApiUsageResponse.php index 98edc9d..86abd81 100644 --- a/src/ZBApiUsageResponse.php +++ b/src/ZBApiUsageResponse.php @@ -203,6 +203,16 @@ class ZBApiUsageResponse extends ZBResponse */ public $subStatusRoleBasedAcceptAll; + /** Total emails with a leading period that was removed + * @var int|null + */ + public $subStatusLeadingPeriodRemoved; + + /** Total number of times the API has a sub status of "alias_address" + * @var int|null + */ + public $subStatusAliasAddress; + /** * Start date of query * @var string|null @@ -253,8 +263,39 @@ public function __toString() ", subStatusBlocked=" . $this->subStatusBlocked . ", subStatusGold=" . $this->subStatusGold . ", subStatusRoleBasedAcceptAll=" . $this->subStatusRoleBasedAcceptAll . + ", subStatusLeadingPeriodRemoved=" . $this->subStatusLeadingPeriodRemoved . + ", subStatusAliasAddress=" . $this->subStatusAliasAddress . ", startDate=" . $this->stringField($this->startDate) . ", endDate=" . $this->stringField($this->endDate) . "}"; } + + /** + * LIVE: four of these counters do not survive the snake_case to camelCase + * conversion the base class does, so they were never populated and stayed + * null for every caller: the API sends `sub_status_antispam_system`, + * `sub_status_greylisted`, `sub_status_unroutable_ip_address` and + * `sub_status_mail_server_did_not_respond`, which convert to + * `subStatusAntispamSystem`, `subStatusGreylisted`, + * `subStatusUnroutableIpAddress` and `subStatusMailServerDidNotRespond` — + * none of which is the property name. Renaming the properties would break + * callers, so the four are mapped explicitly instead. + * + * @param string $key + * @return string + */ + public function getClassKey($key) + { + $overrides = array( + 'sub_status_antispam_system' => 'subStatusAntiSpamSystem', + 'sub_status_greylisted' => 'subStatusGreyListed', + 'sub_status_unroutable_ip_address' => 'subStatusUnRoutableIpAddress', + 'sub_status_mail_server_did_not_respond' => 'subStatusMailServerDidNotResponse', + ); + if (isset($overrides[$key])) { + return $overrides[$key]; + } + return parent::getClassKey($key); + } + }