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..e35bb3c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,88 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project + +`zero-bounce-php-sdk-setup` is the ZeroBounce PHP SDK, package `zero-bounce/sdk` on Packagist. Callers use `ZeroBounce::create()`, then `initialize($apiKey)` or `initialize($apiKey, $baseUrl)`. `ZeroBounce::Instance()` is a process-wide singleton. Failures throw `ZBException`. 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, 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`. + +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. +- **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. +- **Column indexes are 1-based on the API and are not converted.** The SDK forwards the integer it is given, including `0`, for required and optional columns. `sendFile` does not have `remove_duplicate`. That flag is the `remove_duplicate` key on the S3 option array. +- **Booleans are the strings `true` and `false`** on query strings, form bodies, and multipart fields. A PHP bool would be sent as `1` or `0`. On `validateBatch` JSON, `activity_data` and `verify_plus` are JSON booleans. +- **`has_header_row`:** `null` is omitted, `false` is sent as `false`, `true` is sent as `true`. The same rule applies to `allow_phase_2`, `activity_data`, and `verify_plus`. +- **`allow_phase_2` is validation `sendFile` and `validateS3File` only.** Scoring `sendFile` ignores it. `activity_data` on get-file is validation `getFile` only. +- **`validate` timeout is 3–60 seconds. `validateBatch` timeout is 10–120.** An out-of-range timeout throws before a request. `null` omits `timeout`. +- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. Bulk file, email finder, domain search 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. +- **`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/`. Do not use an implicitly nullable parameter (`array $x = null`); 8.4 deprecates it and `?array` needs 7.1. + +## 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. + +**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`. +4. Tag `vx.y.z` on that commit and run the Publish workflow with that tag. It creates the GitHub release and tells Packagist to update `zero-bounce/sdk`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6070c2a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,248 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `ZeroBounce::create()` returns a new client. `Instance()` stays a process-wide singleton. +- `validate` and `validateBatch` take `ZBValidateOptions`: `timeout`, `activityData`, `verifyPlus`, and `usePost` (form-encoded `POST /validate`). Single `timeout` is 3–60 seconds. Batch `timeout` is 10–120. +- `ZBValidateResponse` reads `active_in_days` and `active_first_seen`. +- `scoreEmail` (`GET /v2/scoring`) and `scoreEmailPost` (form-encoded `POST /v2/scoring`). +- Bulk email finder (`sendEmailFinderFile`, `emailFinderFileStatus`, `emailFinderGetFile`, `emailFinderDeleteFile`, plus stream upload). +- Bulk domain search (`sendDomainSearchFile`, `domainSearchFileStatus`, `domainSearchGetFile`, `domainSearchDeleteFile`, plus stream upload). +- List evaluator (`sendListEvaluatorFile`, `getListEvaluatorResult`, `sendEvaluatedFile`, plus stream uploads). +- Filters (`addFilter`, `listFilters`, `deleteFilter`). +- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3Files`, `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`. +- `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 + +- 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. +- `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 + +- 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. +- `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. +- 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. + +### Security + +- API requests do not follow redirects, so a 3xx response cannot send the API key to another host. +- Exception messages no longer include the raw HTML access-denied page. +- 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 + +### Fixed + +- `validateBatch` posts to `api.zerobounce.net/v2/validatebatch` instead of `bulkapi.zerobounce.net`. + +## [2.1.9] - 2026-06-10 + +### Added + +- `ZBValidateResponse.catchallDomain`, read from `catchall_domain`. + +## [2.1.8] - 2026-04-22 + +### Fixed + +- An empty HTTP header list is treated as "no status" instead of raising a warning while reading the status code. + +## [2.1.6] - 2026-04-16 + +### Fixed + +- On PHP 8.5, reading response headers no longer touches the deprecated `$http_response_header` variable. + +## [2.1.1] - 2026-04-15 + +### Fixed + +- `getFile` still reads response headers on PHP 8.5 when `http_get_last_response_headers()` does not return an array. + +## [2.1.0] - 2026-04-05 + +### Added + +- `getFile` and `scoringGetFile` accept `ZBGetFileOptions`: `downloadType` (`phase_1`, `phase_2`, `combined`) and `activityData` (validation `getFile` only). +- `sendFile` and `sendFileStream` accept `allowPhase2`, sent as `allow_phase_2` on validation bulk sendfile only. +- `ZBFileStatusResponse.filePhase2Status`. + +## [2.0.11] - 2026-03-03 + +### Changed + +- License the project under MIT. + +### Removed + +- The Spanish documentation file. + +## [2.0.0] - 2026-02-26 + +### Added + +- `ZBResponse::toArray()`. +- API usage totals for `accept_all`, `mx_forward`, `alternate`, `allowed`, `blocked`, `gold`, and `role_based_accept_all`. + +### Changed + +- `guessFormat` emits `E_USER_DEPRECATED`. Use `findEmail` or `findEmailFormat`. +- A response that is not a JSON object or array throws `ZBException`. + +## [1.2.0] - 2025-10-27 + +### Added + +- `initialize($apiKey, $baseUrl)` with `ZBBaseUrl::API_DEFAULT_URL`, `API_USA_URL`, and `API_EU_URL`. +- `findEmail` and `findEmailFormat`. +- `ZBValidateSubStatus::AcceptAll`. + +### Deprecated + +- `guessFormat`. Use `findEmail` or `findEmailFormat`. + +## [1.1.4] - 2023-11-26 + +### Fixed + +- A body that is not a JSON object or array throws `ZBException` instead of being ignored. +- Response fields start as `null` until the API sets them. + +### Changed + +- Missing parameters, a missing API key, and other SDK failures use separate exception classes. + +## [1.1.3] - 2023-09-05 + +### Added + +- `guessFormat`. + +## [1.1.2] - 2023-08-22 + +### Fixed + +- `getFile` calls the bulk get-file path. + +## [1.1.1] - 2023-08-17 + +### Fixed + +- `sendFile` calls the bulk send-file path. + +## [1.1.0] - 2023-05-17 + +### Added + +- `validateBatch`. + +## [1.0.2] - 2023-05-11 + +### Fixed + +- A missing API response no longer raises a warning. + +## [1.0.1] - 2023-04-27 + +### Fixed + +- Removed an import that broke installation under Laravel. + +## [1.0.0] - 2023-04-06 + +### Added + +- `getActivity`. + +## [0.0.4] - 2020-02-24 + +### Fixed + +- The client runs on PHP 7.0. + +## [0.0.3] - 2020-02-11 + +### Added + +- Scoring bulk file methods: `scoringSendFile`, `scoringFileStatus`, `scoringGetFile`, and `scoringDeleteFile`. + +### Changed + +- The package requires PHP 7.0 or newer. + +## [0.0.2] - 2019-08-05 + +### Changed + +- Composer no longer requires the `ext-spltype` and `ext-http` extensions. + +## [0.0.1] - 2019-08-05 + +### Added + +- Initial release: `getCredits`, `getApiUsage`, `validate`, and the bulk file methods. + +[Unreleased]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.11...HEAD +[2.1.10]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.9...v2.1.10 +[2.1.9]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.8...v2.1.9 +[2.1.8]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.6...v2.1.8 +[2.1.6]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.1...v2.1.6 +[2.1.1]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.1.0...v2.1.1 +[2.1.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.0.11...v2.1.0 +[2.0.11]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v2.0.0...v2.0.11 +[2.0.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.2.0...v2.0.0 +[1.2.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.4...v1.2.0 +[1.1.4]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.3...v1.1.4 +[1.1.3]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.2...v1.1.3 +[1.1.2]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.1...v1.1.2 +[1.1.1]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.1.0...v1.1.1 +[1.1.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.0.2...v1.1.0 +[1.0.2]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v1.0.0...v1.0.1 +[1.0.0]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v0.0.4...v1.0.0 +[0.0.4]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v0.0.3...v0.0.4 +[0.0.3]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v0.0.2...v0.0.3 +[0.0.2]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/compare/v0.0.1...v0.0.2 +[0.0.1]: https://github.com/zerobounce/zero-bounce-php-sdk-setup/releases/tag/v0.0.1 diff --git a/README.md b/README.md index cad28a7..f312409 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,91 @@ $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; +``` + +- 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. +$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 */ @@ -218,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 @@ -282,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 @@ -315,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 049f2ae..cd5841c 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,10 @@ ], "license": "MIT", "scripts": { - "dumpautoload": "composer dumpautoload -o" + "dumpautoload": "composer dumpautoload -o", + "lint": "find src test live -name '*.php' -exec php -l {} \\;", + "test": "phpunit --colors=always", + "test:live": "php live/live_api.php" }, "authors": [ { @@ -25,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": [ { diff --git a/documentation.md b/documentation.md index cbb8d0a..6924496 100644 --- a/documentation.md +++ b/documentation.md @@ -53,6 +53,91 @@ $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; +``` + +- 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. +$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/live/live_api.php b/live/live_api.php new file mode 100644 index 0000000..7455cfb --- /dev/null +++ b/live/live_api.php @@ -0,0 +1,485 @@ +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('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); + }, '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); 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/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); + } + } 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/ZBHttpError.php b/src/ZBHttpError.php new file mode 100644 index 0000000..0ea6c44 --- /dev/null +++ b/src/ZBHttpError.php @@ -0,0 +1,215 @@ + 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']))); + } + + /** + * {"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); + 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', 'detail') 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/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/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/ZBRawJsonResponse.php b/src/ZBRawJsonResponse.php new file mode 100644 index 0000000..f9f5d79 --- /dev/null +++ b/src/ZBRawJsonResponse.php @@ -0,0 +1,32 @@ + */ + public $data = array(); + + /** + * @param string|array $json + * @throws ZBException + */ + public function Deserialize($json) + { + if (is_string($json)) { + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + throw new ZBException(sprintf('Invalid response "%s".', $json)); + } + $this->data = $decoded; + return; + } + if (!is_array($json)) { + throw new ZBException('Deserialize expects a JSON string or array.'); + } + $this->data = $json; + } +} diff --git a/src/ZBScoreEmailResponse.php b/src/ZBScoreEmailResponse.php new file mode 100644 index 0000000..a891588 --- /dev/null +++ b/src/ZBScoreEmailResponse.php @@ -0,0 +1,23 @@ +email . ", " . + "score=" . $this->score . "}"; + } +} diff --git a/src/ZBValidateOptions.php b/src/ZBValidateOptions.php new file mode 100644 index 0000000..5dc38c0 --- /dev/null +++ b/src/ZBValidateOptions.php @@ -0,0 +1,39 @@ +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/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/src/ZeroBounce.php b/src/ZeroBounce.php index b36c9df..f1c3888 100644 --- a/src/ZeroBounce.php +++ b/src/ZeroBounce.php @@ -3,12 +3,15 @@ namespace ZeroBounce\SDK; use DateTime; +use DateTimeInterface; use Exception; /** */ class ZeroBounce { + use ZeroBouncePriority3; + use ZeroBounceInboxPlacement; /** * Call this method to get singleton @@ -24,27 +27,160 @@ 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"; + + /** 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; + /** @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 + */ + public function initialize( + $apiKey, + $baseUrl = null, + $listEvaluatorBaseUrl = null, + $inboxPlacementBaseUrl = null + ) { + $this->apiKey = self::checkedApiKey($apiKey); + $this->apiBaseUrl = self::requireHttpsUrl(ZBBaseUrl::getByValue($baseUrl), 'baseUrl'); + if ($listEvaluatorBaseUrl !== null && $listEvaluatorBaseUrl !== '') { + $this->listEvaluatorBaseUrl = rtrim( + self::requireHttpsUrl($listEvaluatorBaseUrl, 'listEvaluatorBaseUrl'), '/' + ); + } + if ($inboxPlacementBaseUrl !== null && $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 */ - public function initialize($apiKey, $baseUrl = null) + protected static function checkedApiKey($apiKey) { - $this->apiKey = $apiKey; - self::$ApiBaseUrl = ZBBaseUrl::getByValue($baseUrl); + // 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; } /** @@ -55,13 +191,76 @@ public function initialize($apiKey, $baseUrl = null) * @throws ZBMissingParameterException * @throws ZBException */ - public function validate($email, $ipAddress = NULL) + public function validate($email, $ipAddress = NULL, $options = NULL) { $this->checkValidApiKey(); if (!$email) throw new ZBMissingParameterException("email is required"); + if ($options instanceof ZBValidateOptions) { + $this->checkTimeout($options->timeout, false); + // 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(); - $ipPart = $ipAddress ? urlencode($ipAddress) : ""; - $this->request(self::$ApiBaseUrl . "/validate?api_key=" . urlencode($this->apiKey) . "&email=" . urlencode($email) . "&ip_address=" . $ipPart, $response); + $ipValue = ($ipAddress === null) ? '' : (string)$ipAddress; + $fields = array( + 'email' => $email, + 'ip_address' => $ipValue, + ); + if ($options instanceof ZBValidateOptions) { + if ($options->timeout !== null) { + $fields['timeout'] = (string)$options->timeout; + } + if ($options->activityData !== null) { + $fields['activity_data'] = $options->activityData ? "true" : "false"; + } + if ($options->verifyPlus !== null) { + $fields['verify_plus'] = $options->verifyPlus ? "true" : "false"; + } + if ($options->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; + } + } + $url = $this->apiBaseUrl . "/validate?api_key=" . urlencode($this->apiKey) + . "&email=" . urlencode($email) . "&ip_address=" . urlencode($ipValue); + if ($options instanceof ZBValidateOptions) { + if ($options->timeout !== null) { + $url .= "&timeout=" . urlencode((string)$options->timeout); + } + if ($options->activityData !== null) { + $url .= "&activity_data=" . ($options->activityData ? "true" : "false"); + } + if ($options->verifyPlus !== null) { + $url .= "&verify_plus=" . ($options->verifyPlus ? "true" : "false"); + } + 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; } @@ -72,10 +271,13 @@ public function validate($email, $ipAddress = NULL) * @throws ZBMissingParameterException * @throws ZBException */ - public function validateBatch($emails) + public function validateBatch($emails, $options = NULL) { $this->checkValidApiKey(); if (!$emails or !count($emails)) throw new ZBMissingParameterException("emails parameter is required"); + if ($options instanceof ZBValidateOptions) { + $this->checkTimeout($options->timeout, true); + } $params = []; if (gettype($emails[0]) === 'string') @@ -98,10 +300,26 @@ function ($email) { else throw new ZBException('Unknown Parameter Type'); $params = ['email_batch' => $params]; + if ($options instanceof ZBValidateOptions) { + if ($options->timeout !== null) { + // 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; + } + if ($options->verifyPlus !== null) { + $params['verify_plus'] = $options->verifyPlus; + } + } $response = new ZBBatchValidateResponse(); - $code = $this->json( - self::$ApiBaseUrl . "/validatebatch", $params, $response); + $this->json( + $this->apiBaseUrl . "/validatebatch", $params, $response); + $this->throwIfBatchAccountError($response); return $response; } @@ -110,11 +328,11 @@ function ($email) { * @throws ZBMissingApiKeyException * @throws ZBException */ - public function getCredits() + public function getCredits($usePost = false) { $this->checkValidApiKey(); $response = new ZBGetCreditsResponse(); - $this->request(self::$ApiBaseUrl . "/getcredits?api_key=" . urlencode($this->apiKey), $response); + $this->getOrPostForm($this->apiBaseUrl . "/getcredits", array(), $response, $usePost); return $response; } @@ -125,7 +343,7 @@ public function getCredits() * @throws ZBMissingApiKeyException * @throws ZBException */ - public function getApiUsage($startDate, $endDate) + public function getApiUsage($startDate, $endDate, $usePost = false) { $this->checkValidApiKey(); @@ -133,31 +351,92 @@ public function getApiUsage($startDate, $endDate) if (!$endDate) throw new ZBMissingParameterException("endDate is required"); $response = new ZBApiUsageResponse(); - $format = "Y-m-d"; - $this->request(self::$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(self::$ApiBaseUrl . "/activity?api_key=" . urlencode($this->apiKey) - . "&email=" . urlencode($email), - $response); + $this->getOrPostForm( + $this->apiBaseUrl . "/activity", array('email' => $email), $response, $usePost + ); + 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, $usePost = false) + { + $this->checkValidApiKey(); + if (!$email) throw new ZBMissingParameterException("email is required"); + + $response = new ZBScoreEmailResponse(); + $this->getOrPostForm( + $this->apiBaseUrl . "/scoring", array('email' => $email), $response, $usePost + ); + return $response; + } + + /** + * Real-time AI scoring for a single email (POST /scoring, form-encoded). + * @param string $email + * @return ZBScoreEmailResponse + * @throws ZBMissingApiKeyException + * @throws ZBException + */ + public function scoreEmailPost($email) + { + $this->checkValidApiKey(); + if (!$email) throw new ZBMissingParameterException("email is required"); + + $response = new ZBScoreEmailResponse(); + $this->formPost($this->apiBaseUrl . "/scoring", array('email' => $email), $response); return $response; } @@ -271,25 +550,14 @@ private function _sendFile( { $this->checkValidApiKey(); - // data fields for POST request - - if (!$filepath) throw new ZBMissingParameterException("filePath is required"); - if (!$emailAddressColumn) throw new ZBMissingParameterException("emailAddressColumn is required"); + $this->requireProvided($filepath, "filePath is required"); + $this->requireProvided($emailAddressColumn, "emailAddressColumn is required"); try { - $fields = array( - "api_key" => $this->apiKey, - "email_address_column" => $emailAddressColumn + $fields = $this->multipartFileFields( + $emailAddressColumn, $returnUrl, $firstNameColumn, $lastNameColumn, + $genderColumn, $ipAddressColumn, $hasHeaderRow, $allowPhase2, $url ); - if ($returnUrl) $fields["return_url"] = $returnUrl; - if ($firstNameColumn) $fields["first_name_column"] = $firstNameColumn; - if ($lastNameColumn) $fields["last_name_column"] = $lastNameColumn; - if ($genderColumn) $fields["gender_column"] = $genderColumn; - if ($ipAddressColumn) $fields["ip_address_column"] = $ipAddressColumn; - if ($hasHeaderRow) $fields["has_header_row"] = 'true'; - if ($allowPhase2 !== null && strpos($url, '/scoring/') === false) { - $fields['allow_phase_2'] = $allowPhase2 ? 'true' : 'false'; - } $files = array(); $files[$filepath] = file_get_contents($filepath); @@ -324,23 +592,14 @@ private function _sendFileWithContent( $allowPhase2 = null) { $this->checkValidApiKey(); - if (!$fileName) throw new ZBMissingParameterException("fileName is required"); - if (!$emailAddressColumn) throw new ZBMissingParameterException("emailAddressColumn is required"); + $this->requireProvided($fileName, "fileName is required"); + $this->requireProvided($emailAddressColumn, "emailAddressColumn is required"); try { - $fields = array( - "api_key" => $this->apiKey, - "email_address_column" => $emailAddressColumn + $fields = $this->multipartFileFields( + $emailAddressColumn, $returnUrl, $firstNameColumn, $lastNameColumn, + $genderColumn, $ipAddressColumn, $hasHeaderRow, $allowPhase2, $url ); - if ($returnUrl) $fields["return_url"] = $returnUrl; - if ($firstNameColumn) $fields["first_name_column"] = $firstNameColumn; - if ($lastNameColumn) $fields["last_name_column"] = $lastNameColumn; - if ($genderColumn) $fields["gender_column"] = $genderColumn; - if ($ipAddressColumn) $fields["ip_address_column"] = $ipAddressColumn; - if ($hasHeaderRow) $fields["has_header_row"] = 'true'; - if ($allowPhase2 !== null && strpos($url, '/scoring/') === false) { - $fields['allow_phase_2'] = $allowPhase2 ? 'true' : 'false'; - } $files = array($fileName => $content); @@ -364,30 +623,61 @@ protected function json($url, $data, $response) { $data['api_key'] = $this->apiKey; $content = json_encode($data); + $result = $this->executeHttp( + 'POST', + $this->checkedUrl($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); + $json = $this->readJsonBody($result); + $response->Deserialize($json); + return (int)$result['status']; + } - $code = curl_getinfo($curl, CURLINFO_HTTP_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; + $content = http_build_query($data); + $result = $this->executeHttp( + 'POST', + $this->checkedUrl($url), + array('Content-Type: application/x-www-form-urlencoded'), + $content, + 120 + ); - curl_close($curl); + $json = $this->readJsonBody($result); + $response->Deserialize($json); + return (int)$result['status']; + } - if (!$json) { - throw new ZBException("No response"); + /** + * 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); } - - $response->Deserialize($json); - return $code; + $params['api_key'] = $this->apiKey; + return $this->request($url . '?' . http_build_query($params), $response); } /** @@ -399,39 +689,21 @@ protected function json($url, $data, $response) */ protected function curl($url, $fields, $files) { - $curl = curl_init(); - $boundary = bin2hex(random_bytes(16)); $delimiter = '-------------' . $boundary; - $post_data = $this->build_data_files($boundary, $fields, $files); - - curl_setopt_array($curl, array( - CURLOPT_URL => $url, - CURLOPT_RETURNTRANSFER => 1, - CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 120, - //CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, - CURLOPT_CUSTOMREQUEST => "POST", - CURLOPT_POST => 1, - CURLOPT_POSTFIELDS => $post_data, - CURLOPT_HTTPHEADER => array( - //"Authorization: Bearer $TOKEN", + $result = $this->executeHttp( + 'POST', + $this->checkedUrl($url), + array( "Content-Type: multipart/form-data; boundary=" . $delimiter, - "Content-Length: " . strlen($post_data) + "Content-Length: " . strlen($post_data), ), - )); - - $response = curl_exec($curl); - $err = curl_error($curl); - - curl_close($curl); - - /*if ($err && count_chars($err) > 0) { - throw new ZBApiException($err); - }*/ + $post_data, + 120 + ); - return $response; + return $this->readJsonBody($result); } /** @@ -467,7 +739,7 @@ private function _fileStatus($scoring, $fileId) { $this->checkValidApiKey(); - if (!$fileId) throw new ZBMissingParameterException("fileId is required"); + $this->requireProvided($fileId, "fileId is required"); $response = new ZBFileStatusResponse(); $this->request(self::BulkApiBaseUrl @@ -520,8 +792,8 @@ private function _getFile($scoring, $fileId, $downloadPath, $options = null) { $this->checkValidApiKey(); - if (!$fileId) throw new ZBMissingParameterException("fileId is required"); - if (!$downloadPath) throw new ZBMissingParameterException("downloadPath is required"); + $this->requireProvided($fileId, "fileId is required"); + $this->requireProvided($downloadPath, "downloadPath is required"); try { $query = array( @@ -540,32 +812,10 @@ 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 ($options instanceof ZBGetFileOptions && $options->stream) { + return $this->streamBulkDownload($url, $downloadPath); } - - if (self::shouldTreatGetFileBodyAsError($body, $contentType)) { - throw new ZBException(self::formatGetFileErrorMessage($body)); - } - - $folder = dirname($downloadPath); - if ($folder !== '' && $folder !== '.' && !file_exists($folder)) { - mkdir($folder, 0777, true); - } - - if (file_put_contents($downloadPath, $body) === false) { - throw new ZBException('Could not write file'); - } - - $response = new ZBGetFileResponse(); - $response->localFilePath = $downloadPath; - return $response; + return $this->saveBulkDownload($this->getBulkFileHttp($url), $downloadPath); } catch (Exception $e) { throw new ZBException($this->redactApiKey($e->getMessage())); } @@ -591,27 +841,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', $this->checkedUrl($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']); } /** @@ -673,7 +907,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; @@ -708,9 +942,9 @@ 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) { + foreach (array('message', 'error', 'error_message', 'detail') as $k) { if (empty($o[$k])) { continue; } @@ -721,7 +955,7 @@ protected static function formatGetFileErrorMessage($body) return trim($o[$k][0]); } } - return $body; + return ZBHttpError::message(0, $body); } @@ -758,7 +992,7 @@ public function _deleteFile($scoring, $fileId) { $this->checkValidApiKey(); - if (!$fileId) throw new ZBMissingParameterException("fileId is required"); + $this->requireProvided($fileId, "fileId is required"); $response = new ZBDeleteFileResponse(); $this->request(self::BulkApiBaseUrl @@ -771,61 +1005,368 @@ 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 executeHttp($method, $url, array $headers = array(), $body = null, $timeout = 120) + { + // 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 => false, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_MAXREDIRS => 0, + 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; + } + if ($body !== null) { + $curlOpts[CURLOPT_POSTFIELDS] = $body; + } + curl_setopt_array($curl, $curlOpts); + $raw = curl_exec($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + if ($raw === false || $raw === null) { + return array('status' => $status, 'headers' => $responseHeaders, 'body' => ''); + } + return array( + 'status' => $status, + 'headers' => $responseHeaders, + 'body' => (string)$raw, + ); + } + protected function request($url, $response) { 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"); - } - - $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'; - } - if (!is_array($responseHeaders)) { - $responseHeaders = array(); - } - $code = $this->getHttpCode($responseHeaders); + $result = $this->executeHttp('GET', $this->checkedUrl($url), array(), null, 120); + $json = $this->readJsonBody($result); $response->Deserialize($json); - return $code; + 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())); } } /** - * @throws ZBMissingApiKeyException + * @param mixed $value + * @return bool */ - private function checkValidApiKey() + protected function isProvided($value) { - if (!$this->apiKey) throw new ZBMissingApiKeyException("ZeroBounce SDK is not initialized. Please call ZeroBounce::Instance()->initialize(\"API_KEY\") first"); + return $value !== null && $value !== ''; } - private function getHttpCode($httpResponseHeader) + /** + * @param mixed $value + * @param string $message + * @throws ZBMissingParameterException + */ + protected function requireProvided($value, $message) { - if (is_array($httpResponseHeader) && !empty($httpResponseHeader)) { - $parts = explode(' ', $httpResponseHeader[0]); - if (count($parts) > 1) //HTTP/1.0 - return intval($parts[1]); //Get code + if (!$this->isProvided($value)) { + throw new ZBMissingParameterException($message); } - return 0; + } + + /** + * @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); + ZBHttpError::throwIfSuccessIsFalse($decoded, $status, $body, $headers); + 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; + } + + /** + * 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 + */ + 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 + */ + private function checkValidApiKey() + { + if (!$this->apiKey) throw new ZBMissingApiKeyException("ZeroBounce SDK is not initialized. Please call ZeroBounce::Instance()->initialize(\"API_KEY\") first"); } private function build_data_files($boundary, $fields, $files) @@ -887,7 +1428,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; } @@ -904,7 +1445,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(); @@ -917,10 +1459,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) { @@ -932,9 +1471,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(self::$ApiBaseUrl . "/guessformat?$query", $response); + $this->getOrPostForm($this->apiBaseUrl . "/guessformat", $args, $response, $usePost); return $response; } @@ -948,7 +1485,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(); @@ -957,9 +1494,7 @@ public function findEmailFormat($domain, $companyName = null) } $response = new ZBFindEmailFormatResponse(); - $args = [ - 'api_key' => $this->apiKey, - ]; + $args = []; // The API doesn't accept both if ($domain) { @@ -968,9 +1503,7 @@ public function findEmailFormat($domain, $companyName = null) $args['company_name'] = $companyName; } - $query = http_build_query($args); - - $this->request(self::$ApiBaseUrl . "/guessformat?$query", $response); + $this->getOrPostForm($this->apiBaseUrl . "/guessformat", $args, $response, $usePost); return $response; } } diff --git a/src/ZeroBounceInboxPlacement.php b/src/ZeroBounceInboxPlacement.php new file mode 100644 index 0000000..6da9785 --- /dev/null +++ b/src/ZeroBounceInboxPlacement.php @@ -0,0 +1,244 @@ + */ + 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) + { + return self::pathSegment($testId, '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(); + $url = $this->inboxPlacementBaseUrl . $path; + if (!empty($query)) { + $url .= '?' . http_build_query($query); + } + + // 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'; + $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 new file mode 100644 index 0000000..e0afec5 --- /dev/null +++ b/src/ZeroBouncePriority3.php @@ -0,0 +1,532 @@ +requireProvided($domainColumn, 'domainColumn is required'); + $fields = $this->buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow); + return $this->sendBulkRootMultipartFile(self::EmailFinderBulkBaseUrl . '/sendfile', $filepath, $fields); + } + + public function sendEmailFinderFileStream( + $streamContent, + $fileName, + $domainColumn, + $firstNameColumn = null, + $lastNameColumn = null, + $middleNameColumn = null, + $fullNameColumn = null, + $hasHeaderRow = null + ) { + $this->requireProvided($domainColumn, 'domainColumn is required'); + $fields = $this->buildEmailFinderFields($domainColumn, $firstNameColumn, $lastNameColumn, $middleNameColumn, $fullNameColumn, $hasHeaderRow); + return $this->sendBulkRootMultipartContent(self::EmailFinderBulkBaseUrl . '/sendfile', $fileName, $streamContent, $fields); + } + + public function emailFinderFileStatus($fileId) + { + return $this->bulkRootFileStatus(self::EmailFinderBulkBaseUrl, $fileId); + } + + public function emailFinderGetFile($fileId, $downloadPath) + { + return $this->bulkRootGetFile(self::EmailFinderBulkBaseUrl, $fileId, $downloadPath); + } + + public function emailFinderDeleteFile($fileId) + { + return $this->bulkRootDeleteFile(self::EmailFinderBulkBaseUrl, $fileId); + } + + public function sendDomainSearchFile($filepath, $domainColumn, $hasHeaderRow = null) + { + $this->requireProvided($domainColumn, 'domainColumn is required'); + $fields = array('domain_column' => $domainColumn); + if ($hasHeaderRow !== null) { + $fields['has_header_row'] = $hasHeaderRow ? 'true' : 'false'; + } + return $this->sendBulkRootMultipartFile(self::DomainSearchBulkBaseUrl . '/sendfile', $filepath, $fields); + } + + public function sendDomainSearchFileStream($streamContent, $fileName, $domainColumn, $hasHeaderRow = null) + { + $this->requireProvided($domainColumn, 'domainColumn is required'); + $fields = array('domain_column' => $domainColumn); + if ($hasHeaderRow !== null) { + $fields['has_header_row'] = $hasHeaderRow ? 'true' : 'false'; + } + return $this->sendBulkRootMultipartContent(self::DomainSearchBulkBaseUrl . '/sendfile', $fileName, $streamContent, $fields); + } + + public function domainSearchFileStatus($fileId) + { + return $this->bulkRootFileStatus(self::DomainSearchBulkBaseUrl, $fileId); + } + + public function domainSearchGetFile($fileId, $downloadPath) + { + return $this->bulkRootGetFile(self::DomainSearchBulkBaseUrl, $fileId, $downloadPath); + } + + public function domainSearchDeleteFile($fileId) + { + return $this->bulkRootDeleteFile(self::DomainSearchBulkBaseUrl, $fileId); + } + + public function sendListEvaluatorFile($filepath, $emailAddressColumn) + { + $this->requireProvided($emailAddressColumn, 'emailAddressColumn is required'); + $fields = array('email_address_column' => $emailAddressColumn); + return $this->sendBulkRootMultipartFile($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($this->listEvaluatorBaseUrl . '/', $fileName, $streamContent, $fields); + } + + /** + * 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) + { + $this->checkValidApiKey(); + $this->requireProvided($fileId, 'fileId is required'); + $url = $this->listEvaluatorBaseUrl . '/' . self::pathSegment($fileId, 'fileId') + . '/?api_key=' . urlencode($this->apiKey); + $response = new ZBListEvaluatorResponse(); + $this->request($url, $response); + return $response; + } + + /** + * 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) + { + 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) + ); + } + + /** + * @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(); + if (!$this->isProvided($rule) || !$this->isProvided($target) || !$this->isProvided($value)) { + throw new ZBMissingParameterException('rule, target, and value are required'); + } + return $this->filterFormPost($this->apiBaseUrl . '/filters/add', array( + 'rule' => $rule, + 'target' => $target, + 'value' => $value, + )); + } + + /** + * @return array|mixed + */ + public function listFilters($usePost = false) + { + $this->checkValidApiKey(); + if ($usePost) { + return $this->filterFormPost($this->apiBaseUrl . '/filters/list', array()); + } + $url = $this->apiBaseUrl . '/filters/list?api_key=' . urlencode($this->apiKey); + $holder = new ZBRawJsonResponse(); + $this->request($url, $holder); + return $holder->data; + } + + /** + * @return array + */ + public function deleteFilter($rule, $target, $value) + { + $this->checkValidApiKey(); + if (!$this->isProvided($rule) || !$this->isProvided($target) || !$this->isProvided($value)) { + throw new ZBMissingParameterException('rule, target, and value are required'); + } + return $this->filterFormPost($this->apiBaseUrl . '/filters/delete', array( + 'rule' => $rule, + 'target' => $target, + 'value' => $value, + )); + } + + 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) + { + 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($url, $fields, $rsp); + return $rsp; + } + + private function appendOptionalBool(array &$fields, $key, $value) + { + if ($value !== null) { + $fields[$key] = $value ? 'true' : 'false'; + } + } + + /** + * Shared shape for the documented S3 and SFTP file jobs on the bulkapi root. + * 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. + * + * @return ZBSendFileResponse + * @throws ZBException + */ + private function bulkRootFileJob($path, array $options) + { + $fileName = isset($options['file_name']) ? $options['file_name'] : null; + 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'); + } + $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']); + } + $fields['return_url'] = $returnUrl; + 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); + } + + /** + * 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); + if ($hasHeaderRow !== null) { + $fields['has_header_row'] = $hasHeaderRow ? 'true' : 'false'; + } + if ($this->isProvided($firstNameColumn)) { + $fields['first_name_column'] = $firstNameColumn; + } + if ($this->isProvided($lastNameColumn)) { + $fields['last_name_column'] = $lastNameColumn; + } + if ($this->isProvided($middleNameColumn)) { + $fields['middle_name_column'] = $middleNameColumn; + } + if ($this->isProvided($fullNameColumn)) { + $fields['full_name_column'] = $fullNameColumn; + } + return $fields; + } + + + private function 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 bulkRootFileStatus($baseUrl, $fileId) + { + $this->checkValidApiKey(); + $this->requireProvided($fileId, 'fileId is required'); + $response = new ZBFileStatusResponse(); + $this->request( + $baseUrl . '/filestatus?api_key=' . urlencode($this->apiKey) . '&file_id=' . urlencode($fileId), + $response + ); + return $response; + } + + private function bulkRootDeleteFile($baseUrl, $fileId) + { + $this->checkValidApiKey(); + $this->requireProvided($fileId, 'fileId is required'); + $response = new ZBDeleteFileResponse(); + $this->request( + $baseUrl . '/deletefile?api_key=' . urlencode($this->apiKey) . '&file_id=' . urlencode($fileId), + $response + ); + return $response; + } + + private function bulkRootGetFile($baseUrl, $fileId, $downloadPath) + { + $this->checkValidApiKey(); + $this->requireProvided($fileId, 'fileId is required'); + $this->requireProvided($downloadPath, 'downloadPath is required'); + $url = $baseUrl . '/getfile?api_key=' . urlencode($this->apiKey) . '&file_id=' . urlencode($fileId); + return $this->saveBulkDownload($this->getBulkFileHttp($url), $downloadPath); + } + + /** + * @return array + */ + private function filterFormPost($url, array $data) + { + $holder = new ZBRawJsonResponse(); + $this->formPost($url, $data, $holder); + return $holder->data; + } +} diff --git a/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/MockZeroBounce.php b/test/MockZeroBounce.php index c94d69f..bf926ac 100644 --- a/test/MockZeroBounce.php +++ b/test/MockZeroBounce.php @@ -49,6 +49,18 @@ public static function Instance() */ public $lastJsonUrl; + /** + * Last form POST URL passed to formPost(). + * @var string|null + */ + public $lastFormPostUrl; + + /** + * Last GET URL passed to request(). + * @var string|null + */ + public $lastRequestUrl; + /** * Overwrites the HTTP JSON POST request made within the ZeroBounce class * @param string $url @@ -76,6 +88,7 @@ protected function json($url, $data, $response) */ protected function request($url, $response) { + $this->lastRequestUrl = $url; try { $code = 200; $response->Deserialize($this->responseText); @@ -85,6 +98,24 @@ protected function request($url, $response) } } + /** + * @param string $url + * @param array $data + * @param ZBResponse $response + * @return int + * @throws ZBException + */ + protected function formPost($url, $data, $response) + { + $this->lastFormPostUrl = $url; + try { + $response->Deserialize($this->responseText); + return 200; + } catch (Exception $e) { + throw new ZBException($e->getMessage()); + } + } + /** * Overwrites the curl request made within the ZeroBounce class * @param string $url @@ -92,8 +123,14 @@ protected function request($url, $response) * @param array $files * @return string */ + /** + * @var string|null + */ + public $lastCurlUrl; + protected function curl($url, $fields, $files) { + $this->lastCurlUrl = $url; $this->lastMultipartFields = $fields; return $this->responseText; } diff --git a/test/OfflineApiTest.php b/test/OfflineApiTest.php new file mode 100644 index 0000000..4ef5d58 --- /dev/null +++ b/test/OfflineApiTest.php @@ -0,0 +1,1460 @@ +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); + // 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']); + } + + 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->data['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(); + // 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('eval-file-1', $evaluated['fields']['file_id']); + + $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); + } + } + + 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']); + // 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(); + $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(); + // 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); + $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']); + } + + + public function testS3AndSftpJobsSendDocumentedFieldsOnly() + { + $api = new FakeApi(); + $client = $this->client($api, 'bulk-key'); + + $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']); + + $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']); + + // LIVE: validateSFTPfile needs file_name too, although the docs mark it + // optional ("file_name: This field is required"). + $api->clear(); + $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->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, '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, '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, 'return_url' => 'https://example.com/done')); + $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, '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, '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', ' '); }, + ); + foreach ($cases as $case) { + $threw = false; + try { + $case(); + } catch (ZBException $e) { + $threw = true; + } + $this->assertTrue($threw); + } + $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()); + } + + 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]); + } + + 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); + $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..5924a74 --- /dev/null +++ b/test/OfflineHttp.php @@ -0,0 +1,354 @@ +> */ + 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'] : ''; + // 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, + 'host' => $host, + 'path' => $path, + 'headers' => $headers, + '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, + 'authorization' => $authorization, + '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']; + $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()); + } + 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); + } + + 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"); + } + 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}]'); + } + 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); + } + + 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 +{ + public function __construct() + { + parent::__construct(); + } +} 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()); + } +} diff --git a/test/ZeroBounceHttpHeadersPhp85Test.php b/test/ZeroBounceHttpHeadersPhp85Test.php index 7fc119f..e834c39 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,165 +37,48 @@ 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, - '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, - 'Remove isset($http_response_header) / elseif legacy branch; it is deprecated on PHP 8.5 (see RFC deprecations).' - ); } - public function testRequestUsesHttpGetLastResponseHeadersBeforeReadingStatus(): void + public function testExecuteHttpCollectsHeadersAndStatusFromCurl(): void { - $src = file_get_contents(self::zeroBounceSourcePath()); - $method = self::extractMethodSource( + $src = (string)file_get_contents(self::zeroBounceSourcePath()); + $execute = self::extractMethodSource( $src, - 'protected function request($url, $response)', - 'private function checkValidApiKey' + 'protected function executeHttp($method, $url, array $headers = array(), $body = null, $timeout = 120)', + '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); - - $posFn = strpos($method, "function_exists('http_get_last_response_headers')"); - $posGetHttp = strpos($method, 'getHttpCode($responseHeaders)'); - $this->assertNotFalse($posFn); - $this->assertNotFalse($posGetHttp); - $this->assertLessThan( - $posGetHttp, - $posFn, - 'request() must resolve headers via http_get_last_response_headers() 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 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")); - $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; + $probe = new class extends ZeroBounce { + public function __construct() + { + parent::__construct(); } - $zb = new GetBulkFileHttpProbe(); - $out = $zb->callGetBulkFileHttp($url); - $this->assertSame("col1,col2\na@b.com,1\n", $out['body']); - $this->assertIsArray($out['headers']); - } finally { - @unlink($tmp); - } - } - - /** - * Standalone script so PHP 8.5 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\\n"); - exit(1); -} -exit(0); - -PHP; - } -} - -/** - * @internal - */ -final class GetBulkFileHttpProbe extends ZeroBounce -{ - public function __construct() - { - parent::__construct(); - } + public function call(string $url): string + { + return $this->checkedUrl($url); + } + }; - /** @return array{body: string, headers: array} */ - public function callGetBulkFileHttp(string $url): array - { - return $this->getBulkFileHttp($url); + $this->expectException(\ZeroBounce\SDK\ZBMissingParameterException::class); + $probe->call('http://api.zerobounce.net/v2/getcredits'); } } diff --git a/test/ZeroBounceTest.php b/test/ZeroBounceTest.php index 423e034..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 = "{ @@ -248,6 +291,21 @@ public function testGetActivity() $this->assertEquals($response->activeInDays, 180); } + public function testScoreEmail() + { + ZeroBounce::Instance()->responseText = "{ + \"email\": \"mark@gmail.com\", + \"score\": 10 + }"; + $response = ZeroBounce::Instance()->scoreEmail("mark@gmail.com"); + $this->assertEquals($response->email, "mark@gmail.com"); + $this->assertEquals($response->score, 10); + + $responsePost = ZeroBounce::Instance()->scoreEmailPost("mark@gmail.com"); + $this->assertEquals($responsePost->email, "mark@gmail.com"); + $this->assertEquals($responsePost->score, 10); + } + public function testSendFile() { ZeroBounce::Instance()->responseText = "{ @@ -702,4 +760,46 @@ public function testFindEmailFormat() $this->assertEquals($response->confidence, "high"); $this->assertEquals(count($response->otherDomainFormats), 26); } + + public function testSendEmailFinderFileUsesBulkEmailFinderEndpoint() + { + ZeroBounce::Instance()->responseText = '{ + "success": true, + "file_id": "abc-def", + "message": "File Accepted" + }'; + $path = sys_get_temp_dir() . '/zb_finder_p3.csv'; + file_put_contents($path, "domain,name\nacme.com,Jane"); + try { + ZeroBounce::Instance()->sendEmailFinderFile($path, 1); + $this->assertStringContainsString('bulkapi.zerobounce.net/email-finder/sendfile', ZeroBounce::Instance()->lastCurlUrl); + } finally { + if (file_exists($path)) { + unlink($path); + } + } + } + + public function testValidateS3FilePostsToBulkRoot() + { + ZeroBounce::Instance()->responseText = '{ + "success": true, + "file_id": "s3-job", + "message": "File Accepted" + }'; + ZeroBounce::Instance()->validateS3File(array('file_name' => 'bucket/key.csv')); + $this->assertStringContainsString('bulkapi.zerobounce.net/validateS3file', ZeroBounce::Instance()->lastFormPostUrl); + } + + public function testListS3FilesCallsBulkListS3files() + { + ZeroBounce::Instance()->responseText = '[{"file_name":"bucket/key.csv","size":1024}]'; + $result = ZeroBounce::Instance()->listS3Files('my-bucket', 'key'); + $this->assertStringContainsString('bulkapi.zerobounce.net/listS3files', ZeroBounce::Instance()->lastRequestUrl); + $this->assertStringContainsString('bucket=my-bucket', ZeroBounce::Instance()->lastRequestUrl); + $this->assertStringContainsString('search=key', ZeroBounce::Instance()->lastRequestUrl); + $this->assertIsArray($result); + $this->assertEquals('bucket/key.csv', $result[0]['file_name']); + } + }