diff --git a/.github/workflows/sdk_ci.yml b/.github/workflows/sdk_ci.yml index 97a74c0..d1e77c8 100644 --- a/.github/workflows/sdk_ci.yml +++ b/.github/workflows/sdk_ci.yml @@ -25,10 +25,13 @@ jobs: java-version: 21 distribution: 'temurin' - # Execute unit tests. + - name: Typecheck + run: cd zero-bounce-sdk && mvn -B -DskipTests compile + + # Execute unit tests. This includes the offline fake-API suite. - name: Run unit tests - run: cd zero-bounce-sdk && mvn test + run: cd zero-bounce-sdk && mvn -B test # Compile the project to check for errors. - name: Compile project - run: cd zero-bounce-sdk && mvn clean install + run: cd zero-bounce-sdk && mvn -B clean install diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3f9c24d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project + +`zero-bounce-java-sdk-setup` is the ZeroBounce Java SDK plus a small JavaFX sample. The library that ships is `zero-bounce-sdk` (`com.zerobounce.java:zerobouncesdk` on Maven Central). Callers construct a `ZeroBounceSDK` or use `getInstance()`, call `initialize`, and receive results through success and error callbacks. + +## Layout + +- `zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java`: public client. +- `zero-bounce-sdk/src/main/java/com/zerobounce/Priority3Api.java`: bulk email finder, domain search, list evaluator, filters, S3. +- `zero-bounce-sdk/src/main/java/com/zerobounce/HttpMessages.java`: turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short `ErrorResponse`. +- `zero-bounce-sdk/src/main/java/com/zerobounce/SdkHttp.java`: per-client HTTP seam. Production leaves `transport` null. +- `zero-bounce-sdk/src/test/java/com/zerobounce/OfflineApiTest.java`: offline suite. `FakeApi` records every request and answers without the network. +- `zero-bounce-sdk/pom.xml`: SDK build. The version is `` inside ``. +- `pom.xml`: the JavaFX sample. Its `` is `1.0-SNAPSHOT` and is not the SDK version. +- `.github/workflows/sdk_ci.yml`: typecheck, `mvn test`, then `mvn clean install`. +- `.github/workflows/publish.yml`: manual Publish workflow. It deploys the tag to Maven Central. + +## Commands + +From `zero-bounce-sdk`: + +```bash +mvn -B -DskipTests compile # typecheck; there is no separate style linter +mvn -B test # unit tests, including OfflineApiTest +``` + +CI runs the same compile and `mvn test`. `mvn test` is the default test command. + +## Conventions + +- **One `ZeroBounceSDK` per API key.** `getInstance()` is a process-wide singleton. Do not reuse it across users or requests. Construct `new ZeroBounceSDK()` per key. The offline concurrency test does this. Do not share one instance across threads: `apiKey` is mutable and `SimpleDateFormat` is not thread-safe. +- **The SDK version is ``.** Never edit the first `` tag in `zero-bounce-sdk/pom.xml`; that tag is `${sdk.version}`. Plugin and dependency versions must not be copied from ``. +- **Column indexes are 1-based for file uploads.** `sendFile`'s email column must be at least 1. Optional name, gender, and IP columns default to `0`, and `0` means the field is left out. `ValidateS3FileOptions.emailAddressColumn` is an `Integer`: when it is set, `0` is sent. +- **Optional parameters are omitted when unset.** `timeout`, `activityData`, `verifyPlus`, `allowPhase2`, `downloadType`, and `hasHeaderRow` are not sent until the caller sets them. Explicit `false` is sent as `false`. `timeout` `0` is sent. +- **`allow_phase_2` is validation `sendFile` and `validateS3File` only.** Scoring uploads ignore it. `activity_data` on get-file is validation only. +- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. +- **Booleans are the strings `true` and `false` on form and multipart fields, and JSON booleans in a JSON body.** +- **Failures use the error callback**, not thrown server errors. `ZBException` is only for invalid arguments such as an email column below 1. The message must be short: no raw HTML, no megabyte body, and no `Error: Error:` prefix. An account-level `validateBatch` error (`email_address` `all`) is an error callback. A per-address error stays on the success result. +- **Tests replace `transport`.** It is package-visible. Assign a `FakeApi` (or another `SdkHttp.Transport`) on that instance. Do not call the live API from the default test run. +- There is no runtime version constant and no User-Agent. Do not add one that can drift from ``. +- Match the surrounding style. Do not reformat unrelated code. + +## Changelog + +`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). + +- Add every user-visible change to `## [Unreleased]` in the same commit as the change. +- Group entries under these headings, in this order, omitting empty ones: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. +- Write for SDK users: name the method, option, or behaviour, and say what changed. Leave out CI-only changes, internal refactors, and fixes to code that was never released. +- At 2.y.z, a breaking change bumps the major version, a new method or option bumps the minor version, and fixes alone bump the patch version. + +## Releasing + +Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. + +1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links at the bottom of `CHANGELOG.md`. +2. Set the same version in `` in `zero-bounce-sdk/pom.xml`. Do not edit the first `` tag. The sample `pom.xml` dependency on `com.zerobounce.java:zerobouncesdk` is a separate pin; update it only when the sample should depend on the new release. +3. `mvn -B -DskipTests compile` and `mvn -B test` in `zero-bounce-sdk`. +4. Tag `vx.y.z` on that commit and run the Publish workflow with that tag. It deploys to Maven Central and creates the GitHub release. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cf5b830 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,125 @@ +# 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 + +- `validateWithOptions` takes `ZBValidateRequestOptions`: `timeout`, `activityData`, `verifyPlus`, and `usePost` (form-encoded `POST /v2/validate`). +- `validateBatchWithOptions` sends `timeout`, `activityData`, and `verifyPlus` on `POST /v2/validatebatch`. +- `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`). `has_header_row` is sent as `true` or `false` when it is set. +- Bulk domain search (`sendDomainSearchFile`, `domainSearchFileStatus`, `domainSearchGetFile`, `domainSearchDeleteFile`). `has_header_row` is sent as `true` or `false` when it is set. +- List evaluator (`sendListEvaluatorFile`, `getListEvaluatorResult`, `sendEvaluatedFile`). +- Filters (`addFilter`, `listFilters`, `deleteFilter`). +- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3Files`). `email_address_column` is sent when it is set, including `0`. +- `new ZeroBounceSDK()` builds a client for one API key. `getInstance()` remains a process-wide singleton. + +### Changed + +- A custom API base URL must start with `https://`. +- API requests time out after 120 seconds by default and do not follow redirects. + +### Fixed + +- A 403 HTML "Access denied" page is returned as a short error. An HTML page with status 200 is an error as well, including `getFile`, `emailFinderGetFile`, and `domainSearchGetFile`, instead of a saved file. +- Empty and non-JSON bodies are a short error. The message is no longer a Gson parser exception, and a repeated `Error:` / `[Error]:` prefix is removed. +- A Cloudflare JSON 403 (`error_code` 1020) is the title "Error 1020: Access denied", not the raw body. +- `validateBatch` reports an account-level failure (HTTP 200, error for address `all`, such as an invalid API key or no credits) through the error callback. An error for one address stays on the successful result. +- An empty API key, a blank email, a blank file id, and a blank filter rule, target, or value are rejected before a request is sent. +- `findEmail` and `findDomain` treat a blank domain or company name as unset. + +### Security + +- Redirects are disabled, and query values are percent-encoded, so a parameter cannot rewrite the request URL or send the client to another host. +- Error callbacks and exception messages no longer include the raw HTML access-denied page. Exception text replaces the API key with `***`. + +## [2.1.4] - 2026-06-09 + +### Added + +- `ZBValidateResponse.catchallDomain`, read from `catchall_domain`. + +## [2.1.0] - 2026-04-05 + +### Added + +- `getFile` and `scoringGetFile` accept `ZBGetFileOptions`, with `downloadType` (`phase_1`, `phase_2`, `combined`) and `activityData` (validation `getFile` only). +- `SendFileOptions.setAllowPhase2`, sent as `allow_phase_2` on validation `sendFile` only. +- `ZBFileStatusResponse.filePhase2Status`. + +### Fixed + +- `getFile` calls the error callback when the body is a JSON error, including HTTP 200 with `"success": false`, instead of saving that body as the results file. + +## [2.0.16] - 2026-03-03 + +### Changed + +- License the project under MIT. + +## [2.0.0] - 2026-03-03 + +### Added + +- Stream overloads of `sendFile` and `scoringSendFile`. +- Additional counters on `ZBGetApiUsageResponse`. +- `ZBValidateStatus.NONE` and further `ZBValidateSubStatus` values, including `ACCEPT_ALL`, `ROLE_BASED_ACCEPT_ALL`, and `GOLD`. + +## [1.3.0] - 2025-11-06 + +### Added + +- `findEmail` and `findDomain`. + +### Changed + +- The SDK builds with Java 21. + +### Deprecated + +- `guessFormat`. Use `findEmail` or `findDomain`. + +## [1.2.0] - 2025-01-13 + +### Added + +- ISO-8601 values in response dates. + +### Fixed + +- Query values, including the email address, are percent-encoded. + +## [1.1.9] - 2024-09-17 + +### Added + +- `initialize(apiKey, timeoutInMillis)`. `0` means no timeout. + +## [1.1.7] - 2023-08-25 + +### Fixed + +- `ZBValidateStatus` and `ZBValidateSubStatus` are public. + +## [1.0.0] - 2019-12-04 + +### Added + +- Initial release, including validation, bulk file, credits, activity, and AI scoring file methods. + +[Unreleased]: https://github.com/zerobounce/zero-bounce-java-sdk-setup/compare/v2.1.5...HEAD +[2.1.4]: https://github.com/zerobounce/zero-bounce-java-sdk-setup/compare/v2.1.0...v2.1.4 +[2.1.0]: https://github.com/zerobounce/zero-bounce-java-sdk-setup/compare/v2.0.16...v2.1.0 +[2.0.16]: https://github.com/zerobounce/zero-bounce-java-sdk-setup/compare/v2.0.0...v2.0.16 +[2.0.0]: https://github.com/zerobounce/zero-bounce-java-sdk-setup/compare/v1.3.0...v2.0.0 +[1.3.0]: https://github.com/zerobounce/zero-bounce-java-sdk-setup/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/zerobounce/zero-bounce-java-sdk-setup/compare/v1.1.9...v1.2.0 +[1.1.9]: https://github.com/zerobounce/zero-bounce-java-sdk-setup/compare/v1.1.7...v1.1.9 +[1.1.7]: https://github.com/zerobounce/zero-bounce-java-sdk-setup/compare/v1.0.0...v1.1.7 +[1.0.0]: https://github.com/zerobounce/zero-bounce-java-sdk-setup/releases/tag/v1.0.0 diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/HttpMessages.java b/zero-bounce-sdk/src/main/java/com/zerobounce/HttpMessages.java new file mode 100644 index 0000000..8bf650f --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/HttpMessages.java @@ -0,0 +1,276 @@ +package com.zerobounce; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short message. + */ +final class HttpMessages { + + private HttpMessages() { + } + + static boolean looksLikeHtml(String body, String contentType) { + if (contentType != null && contentType.toLowerCase().contains("text/html")) { + return true; + } + if (body == null) { + return false; + } + String sample = body.substring(0, Math.min(body.length(), 2000)).toLowerCase(); + return sample.contains(" 299) { + return messageFromBody(trimmed, status); + } + if (trimmed.charAt(0) != '{' && trimmed.charAt(0) != '[') { + return "ZeroBounce returned a response that is not valid JSON."; + } + try { + JsonParser.parseString(trimmed); + return null; + } catch (RuntimeException ignored) { + return "ZeroBounce returned a response that is not valid JSON."; + } + } + + /** + * @return a short error for a file download, or null when the bytes should be saved + */ + static String downloadFailure(int status, byte[] body, String contentType) { + String text = body == null ? "" : new String(body, java.nio.charset.StandardCharsets.UTF_8); + String trimmed = text.trim(); + if (trimmed.isEmpty()) { + return "Empty response from ZeroBounce (HTTP " + status + ")"; + } + if (looksLikeHtml(text, contentType)) { + return htmlMessage(text); + } + if (status > 299) { + return messageFromBody(trimmed, status); + } + return null; + } + + /** + * Account-level batch error text, or null when the payload is a normal or partial result. + */ + static String batchAccountError(String body) { + try { + JsonElement parsed = JsonParser.parseString(body); + if (!parsed.isJsonObject()) { + return null; + } + JsonObject object = parsed.getAsJsonObject(); + if (!object.has("errors") || !object.get("errors").isJsonArray()) { + return null; + } + for (JsonElement item : object.getAsJsonArray("errors")) { + if (!item.isJsonObject()) { + continue; + } + JsonObject error = item.getAsJsonObject(); + String address = text(error.get("email_address")); + if ("all".equalsIgnoreCase(address)) { + String message = text(error.get("error")); + return message == null || message.isBlank() + ? "Invalid API Key or your account ran out of credits" + : stripPrefix(message); + } + } + } catch (RuntimeException ignored) { + return null; + } + return null; + } + + /** + * @return a short message when a 2xx JSON object is an API error, or null to decode it + */ + static String successPayloadError(String body) { + try { + JsonElement parsed = JsonParser.parseString(body); + if (!parsed.isJsonObject()) { + return null; + } + JsonObject object = parsed.getAsJsonObject(); + if (object.has("email_batch")) { + return batchAccountError(body); + } + if (object.has("success") && object.get("success").isJsonPrimitive() + && !object.get("success").getAsBoolean()) { + return firstText(object, "message", "error", "error_message"); + } + if (object.has("error") && !object.get("error").isJsonNull()) { + String message = text(object.get("error")); + if (message != null && !message.isBlank()) { + return stripPrefix(message); + } + } + if (object.has("error_message")) { + String message = text(object.get("error_message")); + if (message != null && !message.isBlank()) { + return stripPrefix(message); + } + } + } catch (RuntimeException ignored) { + return null; + } + return null; + } + + private static String firstText(JsonObject object, String... keys) { + for (String key : keys) { + String message = text(object.get(key)); + if (message != null && !message.isBlank()) { + return stripPrefix(message); + } + } + return "Request not processed successfully."; + } + + private static String text(JsonElement element) { + if (element == null || element.isJsonNull()) { + return null; + } + if (element.isJsonPrimitive()) { + return element.getAsString(); + } + if (element.isJsonArray() && !element.getAsJsonArray().isEmpty()) { + JsonElement first = element.getAsJsonArray().get(0); + if (first.isJsonPrimitive()) { + return first.getAsString(); + } + } + return element.toString(); + } + + private static String htmlMessage(String body) { + String title = htmlTitle(body); + if (title.isEmpty()) { + return "ZeroBounce returned an HTML error page instead of an API response."; + } + return "ZeroBounce returned an HTML error page (\"" + title + "\") instead of an API response."; + } + + private static String htmlTitle(String body) { + String lower = body.toLowerCase(); + int start = lower.indexOf("', start); + if (openEnd < 0) { + return ""; + } + int close = lower.indexOf("", openEnd); + if (close < 0) { + return ""; + } + return body.substring(openEnd + 1, close).trim(); + } + + private static String messageFromBody(String trimmed, int status) { + if (trimmed.charAt(0) == '{' || trimmed.charAt(0) == '[') { + try { + JsonElement parsed = JsonParser.parseString(trimmed); + if (parsed.isJsonObject()) { + JsonObject object = parsed.getAsJsonObject(); + if (object.has("title") && object.get("title").isJsonPrimitive()) { + String title = object.get("title").getAsString(); + boolean accessDenied = title.contains("Access denied") || title.contains("1020"); + boolean cloudflare = object.has("cloudflare_error") + && object.get("cloudflare_error").isJsonPrimitive() + && object.get("cloudflare_error").getAsBoolean(); + if (accessDenied && (cloudflare || !hasErrorOrMessageKey(object))) { + return title; + } + } + String apiError = successPayloadError(trimmed); + if (apiError != null) { + return apiError; + } + } + } catch (RuntimeException ignored) { + return "ZeroBounce returned a response that is not valid JSON."; + } + if (trimmed.length() > 1000) { + return "ZeroBounce returned a response that is not valid JSON."; + } + return stripPrefix(trimmed); + } + if (trimmed.length() > 1000) { + return "ZeroBounce returned a response that is not valid JSON."; + } + String message = stripPrefix(trimmed); + if (message.isEmpty()) { + return "Empty response from ZeroBounce (HTTP " + status + ")"; + } + return message; + } + + private static boolean hasErrorOrMessageKey(JsonObject object) { + for (String key : object.keySet()) { + if (key.contains("error") || key.contains("message")) { + return true; + } + } + return false; + } + + static boolean preferShortMessage(String body) { + if (body == null) { + return true; + } + String trimmed = body.trim(); + if (trimmed.isEmpty() || looksLikeHtml(trimmed, null) || trimmed.length() > 1000) { + return true; + } + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + return true; + } + try { + JsonElement parsed = JsonParser.parseString(trimmed); + if (!parsed.isJsonObject()) { + return false; + } + JsonObject object = parsed.getAsJsonObject(); + if (object.has("cloudflare_error") && object.get("cloudflare_error").isJsonPrimitive() + && object.get("cloudflare_error").getAsBoolean()) { + return true; + } + if (object.has("title") && object.get("title").isJsonPrimitive() + && object.get("title").getAsString().contains("1020")) { + return true; + } + return false; + } catch (RuntimeException ignored) { + return true; + } + } + + static String stripPrefix(String message) { + String current = message == null ? "" : message.trim(); + while (current.startsWith("Error:") || current.startsWith("[Error]:")) { + current = current.substring(current.startsWith("[Error]:") ? 8 : 6).trim(); + } + return current; + } +} diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/Priority3Api.java b/zero-bounce-sdk/src/main/java/com/zerobounce/Priority3Api.java new file mode 100644 index 0000000..78e7231 --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/Priority3Api.java @@ -0,0 +1,652 @@ +package com.zerobounce; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.reflect.TypeToken; +import org.apache.http.HttpEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.entity.mime.content.FileBody; +import org.apache.http.entity.mime.content.InputStreamBody; +import org.apache.http.entity.mime.content.StringBody; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +final class Priority3Api { + + private final ZeroBounceSDK sdk; + + Priority3Api(ZeroBounceSDK sdk) { + this.sdk = sdk; + } + + void sendEmailFinderFile( + @NotNull File file, + int domainColumn, + @Nullable ZeroBounceSDK.EmailFinderSendFileOptions options, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (domainColumn < 1) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: domain_column")); + return; + } + String url = sdk.priority3EmailFinderBaseUrl() + "/sendfile"; + sendBulkRootMultipart(url, file, buildEmailFinderFields(domainColumn, options), successCallback, errorCallback); + } + + void sendEmailFinderFileStream( + @NotNull InputStream inputStream, + @NotNull String fileName, + int domainColumn, + @Nullable ZeroBounceSDK.EmailFinderSendFileOptions options, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (domainColumn < 1) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: domain_column")); + return; + } + String url = sdk.priority3EmailFinderBaseUrl() + "/sendfile"; + sendBulkRootMultipartStream(url, inputStream, fileName, buildEmailFinderFields(domainColumn, options), successCallback, errorCallback); + } + + private Map buildEmailFinderFields(int domainColumn, @Nullable ZeroBounceSDK.EmailFinderSendFileOptions options) { + Map fields = new HashMap<>(); + fields.put("domain_column", String.valueOf(domainColumn)); + if (options != null) { + if (options.hasHeaderRow != null) { + fields.put("has_header_row", options.hasHeaderRow ? "true" : "false"); + } + if (options.firstNameColumn > 0) { + fields.put("first_name_column", String.valueOf(options.firstNameColumn)); + } + if (options.lastNameColumn > 0) { + fields.put("last_name_column", String.valueOf(options.lastNameColumn)); + } + if (options.middleNameColumn > 0) { + fields.put("middle_name_column", String.valueOf(options.middleNameColumn)); + } + if (options.fullNameColumn > 0) { + fields.put("full_name_column", String.valueOf(options.fullNameColumn)); + } + } + return fields; + } + + void sendDomainSearchFile( + @NotNull File file, + int domainColumn, + @Nullable ZeroBounceSDK.DomainSearchSendFileOptions options, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (domainColumn < 1) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: domain_column")); + return; + } + sendBulkRootMultipart( + sdk.priority3DomainSearchBaseUrl() + "/sendfile", + file, + buildDomainSearchFields(domainColumn, options), + successCallback, + errorCallback + ); + } + + void sendDomainSearchFileStream( + @NotNull InputStream inputStream, + @NotNull String fileName, + int domainColumn, + @Nullable ZeroBounceSDK.DomainSearchSendFileOptions options, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (domainColumn < 1) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: domain_column")); + return; + } + sendBulkRootMultipartStream( + sdk.priority3DomainSearchBaseUrl() + "/sendfile", + inputStream, + fileName, + buildDomainSearchFields(domainColumn, options), + successCallback, + errorCallback + ); + } + + private Map buildDomainSearchFields(int domainColumn, @Nullable ZeroBounceSDK.DomainSearchSendFileOptions options) { + Map fields = new HashMap<>(); + fields.put("domain_column", String.valueOf(domainColumn)); + if (options != null && options.hasHeaderRow != null) { + fields.put("has_header_row", options.hasHeaderRow ? "true" : "false"); + } + return fields; + } + + void bulkRootFileStatus( + @NotNull String baseUrl, + @NotNull String fileId, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + sdk.priority3SendRequest( + baseUrl + "/filestatus", + new HashMap<>() { + { + put("api_key", sdk.priority3ApiKey()); + put("file_id", fileId); + } + }, + null, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + } + + void bulkRootGetFile( + @NotNull String baseUrl, + @NotNull String fileId, + @NotNull String downloadPath, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + try { + String url = baseUrl + "/getfile?api_key=" + + java.net.URLEncoder.encode(sdk.priority3ApiKey(), StandardCharsets.UTF_8) + + "&file_id=" + java.net.URLEncoder.encode(fileId, StandardCharsets.UTF_8); + if (sdk.transport != null) { + SdkHttp.Result result = sdk.transport.exchange(new SdkHttp("GET", url, null, null, null)); + finishBulkDownload(downloadPath, result.status, result.body, successCallback, errorCallback); + return; + } + java.net.HttpURLConnection con = (java.net.HttpURLConnection) sdk.createUrlFrom(url).openConnection(); + con.setInstanceFollowRedirects(false); + con.setConnectTimeout(sdk.priority3TimeoutMillis()); + con.setReadTimeout(sdk.priority3TimeoutMillis()); + int status = con.getResponseCode(); + InputStream stream = status > 299 ? con.getErrorStream() : con.getInputStream(); + byte[] body = stream.readAllBytes(); + con.disconnect(); + finishBulkDownload(downloadPath, status, body, successCallback, errorCallback); + } catch (Exception e) { + errorCallback.onError(ErrorResponse.parseError(sdk.redactedFailure(e))); + } + } + + void bulkRootDeleteFile( + @NotNull String baseUrl, + @NotNull String fileId, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + sdk.priority3SendRequest( + baseUrl + "/deletefile", + new HashMap<>() { + { + put("api_key", sdk.priority3ApiKey()); + put("file_id", fileId); + } + }, + null, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + } + + void sendListEvaluatorFile( + @NotNull File file, + int emailAddressColumn, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (emailAddressColumn < 1) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: email_address_column")); + return; + } + Map fields = new HashMap<>(); + fields.put("email_address_column", String.valueOf(emailAddressColumn)); + sendBulkRootMultipart(sdk.priority3BulkApiBaseUrl() + "/listevaluator/", file, fields, successCallback, errorCallback); + } + + void sendListEvaluatorFileStream( + @NotNull InputStream inputStream, + @NotNull String fileName, + int emailAddressColumn, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (emailAddressColumn < 1) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: email_address_column")); + return; + } + Map fields = new HashMap<>(); + fields.put("email_address_column", String.valueOf(emailAddressColumn)); + sendBulkRootMultipartStream( + sdk.priority3BulkApiBaseUrl() + "/listevaluator/", + inputStream, + fileName, + fields, + successCallback, + errorCallback + ); + } + + void getListEvaluatorResult( + @NotNull String fileId, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + sdk.priority3SendRequest( + sdk.priority3BulkApiBaseUrl() + "/listevaluator/" + fileId + "/", + new HashMap<>() { + { + put("api_key", sdk.priority3ApiKey()); + } + }, + null, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + } + + void sendEvaluatedFile( + @NotNull File file, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + sendBulkRootMultipart( + sdk.priority3BulkApiBaseUrl() + "/send-evaluated-file/", + file, + new HashMap<>(), + successCallback, + errorCallback + ); + } + + void addFilter( + @NotNull String rule, + @NotNull String target, + @NotNull String value, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (rule.isBlank() || target.isBlank() || value.isBlank()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: filter")); + return; + } + sdk.priority3SendFormPost( + sdk.priority3ApiBaseUrl() + "/filters/add", + new HashMap<>() { + { + put("api_key", sdk.priority3ApiKey()); + put("rule", rule); + put("target", target); + put("value", value); + } + }, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + } + + void listFilters( + boolean usePost, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (usePost) { + sdk.priority3SendFormPost( + sdk.priority3ApiBaseUrl() + "/filters/list", + new HashMap<>() { + { + put("api_key", sdk.priority3ApiKey()); + } + }, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + } else { + sdk.priority3SendRequest( + sdk.priority3ApiBaseUrl() + "/filters/list", + new HashMap<>() { + { + put("api_key", sdk.priority3ApiKey()); + } + }, + null, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + } + } + + void deleteFilter( + @NotNull String rule, + @NotNull String target, + @NotNull String value, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + sdk.priority3SendFormPost( + sdk.priority3ApiBaseUrl() + "/filters/delete", + new HashMap<>() { + { + put("api_key", sdk.priority3ApiKey()); + put("rule", rule); + put("target", target); + put("value", value); + } + }, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + } + + void listS3Files( + @Nullable String bucket, + @Nullable String search, + @NotNull ZeroBounceSDK.OnSuccessCallback> successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + java.util.HashMap params = new java.util.HashMap<>(); + params.put("api_key", sdk.priority3ApiKey()); + if (bucket != null && !bucket.trim().isEmpty()) { + params.put("bucket", bucket); + } + if (search != null && !search.trim().isEmpty()) { + params.put("search", search); + } + sdk.priority3SendRequest( + sdk.priority3BulkApiRootUrl() + "/listS3files", + params, + null, + new TypeToken>() { + }, + successCallback, + errorCallback + ); + } + + void validateS3File( + @NotNull ZeroBounceSDK.ValidateS3FileOptions options, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (options.fileName == null || options.fileName.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: file_name")); + return; + } + postBulkRootForm("/validateS3file", buildValidateS3Fields(options), successCallback, errorCallback); + } + + void scoreS3File( + @NotNull ZeroBounceSDK.ScoreS3FileOptions options, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (options.fileName == null || options.fileName.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: file_name")); + return; + } + Map fields = new HashMap<>(); + fields.put("file_name", options.fileName); + putOptionalBool(fields, "has_header_row", options.hasHeaderRow); + putOptionalBool(fields, "remove_duplicates", options.removeDuplicates); + putOptionalBool(fields, "remove_duplicate", options.removeDuplicate); + if (options.emailAddressColumn != null) { + fields.put("email_address_column", String.valueOf(options.emailAddressColumn)); + } + postBulkRootForm("/scoreS3file", fields, successCallback, errorCallback); + } + + void exportS3File( + @NotNull String fileId, + @NotNull String exportBucket, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (fileId.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: file_id")); + return; + } + if (exportBucket.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: export_bucket")); + return; + } + Map fields = new HashMap<>(); + fields.put("file_id", fileId); + fields.put("export_bucket", exportBucket); + postBulkRootForm("/exportS3file", fields, successCallback, errorCallback); + } + + private Map buildValidateS3Fields(ZeroBounceSDK.ValidateS3FileOptions options) { + Map fields = new HashMap<>(); + fields.put("file_name", options.fileName); + putOptionalBool(fields, "has_header_row", options.hasHeaderRow); + putOptionalBool(fields, "remove_duplicates", options.removeDuplicates); + putOptionalBool(fields, "remove_duplicate", options.removeDuplicate); + if (options.emailAddressColumn != null) { + fields.put("email_address_column", String.valueOf(options.emailAddressColumn)); + } + if (options.returnUrl != null) { + fields.put("return_url", options.returnUrl); + } + if (options.firstNameColumn > 0) { + fields.put("first_name_column", String.valueOf(options.firstNameColumn)); + } + if (options.lastNameColumn > 0) { + fields.put("last_name_column", String.valueOf(options.lastNameColumn)); + } + if (options.genderColumn > 0) { + fields.put("gender_column", String.valueOf(options.genderColumn)); + } + if (options.ipAddressColumn > 0) { + fields.put("ip_address_column", String.valueOf(options.ipAddressColumn)); + } + if (options.allowPhase2 != null) { + fields.put("allow_phase_2", Boolean.toString(options.allowPhase2)); + } + return fields; + } + + private void putOptionalBool(Map fields, String key, @Nullable Boolean value) { + if (value != null) { + fields.put(key, String.valueOf(value)); + } + } + + private void postBulkRootForm( + String path, + Map fields, + ZeroBounceSDK.OnSuccessCallback successCallback, + ZeroBounceSDK.OnErrorCallback errorCallback + ) { + Map form = new HashMap<>(fields); + form.put("api_key", sdk.priority3ApiKey()); + sdk.priority3SendFormPost( + sdk.priority3BulkApiRootUrl() + path, + form, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + } + + private void sendBulkRootMultipart( + String url, + File file, + Map fields, + ZeroBounceSDK.OnSuccessCallback successCallback, + ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (deliverMultipartTransport(url, fields, file.getName(), successCallback, errorCallback)) { + return; + } + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpPost uploadFile = new HttpPost(url); + uploadFile.setConfig(sdk.apacheRequestConfig()); + MultipartEntityBuilder builder = MultipartEntityBuilder.create(); + for (Map.Entry entry : fields.entrySet()) { + builder.addPart(entry.getKey(), new StringBody(entry.getValue(), ContentType.TEXT_PLAIN)); + } + builder.addPart("api_key", new StringBody(sdk.priority3ApiKey(), ContentType.TEXT_PLAIN)); + builder.addPart("file", new FileBody(file)); + uploadFile.setEntity(builder.build()); + handleMultipartResponse(httpClient.execute(uploadFile), successCallback, errorCallback); + } catch (Exception e) { + errorCallback.onError(ErrorResponse.parseError(sdk.redactedFailure(e))); + } + } + + private void sendBulkRootMultipartStream( + String url, + InputStream inputStream, + String fileName, + Map fields, + ZeroBounceSDK.OnSuccessCallback successCallback, + ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (deliverMultipartTransport(url, fields, fileName, successCallback, errorCallback)) { + return; + } + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpPost uploadFile = new HttpPost(url); + uploadFile.setConfig(sdk.apacheRequestConfig()); + MultipartEntityBuilder builder = MultipartEntityBuilder.create(); + for (Map.Entry entry : fields.entrySet()) { + builder.addPart(entry.getKey(), new StringBody(entry.getValue(), ContentType.TEXT_PLAIN)); + } + builder.addPart("api_key", new StringBody(sdk.priority3ApiKey(), ContentType.TEXT_PLAIN)); + builder.addPart("file", new InputStreamBody(inputStream, ContentType.DEFAULT_BINARY, fileName)); + uploadFile.setEntity(builder.build()); + handleMultipartResponse(httpClient.execute(uploadFile), successCallback, errorCallback); + } catch (Exception e) { + errorCallback.onError(ErrorResponse.parseError(sdk.redactedFailure(e))); + } + } + + private void finishBulkDownload( + String downloadPath, + int status, + byte[] body, + ZeroBounceSDK.OnSuccessCallback successCallback, + ZeroBounceSDK.OnErrorCallback errorCallback + ) throws IOException { + String downloadError = HttpMessages.downloadFailure(status, body, null); + if (downloadError != null) { + String text = new String(body, StandardCharsets.UTF_8); + if (HttpMessages.preferShortMessage(text)) { + errorCallback.onError(ErrorResponse.parseError(downloadError)); + } else { + errorCallback.onError(ErrorResponse.parseError(text)); + } + return; + } + String bodyStr = new String(body, StandardCharsets.UTF_8).trim(); + if (bodyStr.startsWith("{")) { + try { + JsonObject obj = JsonParser.parseString(bodyStr).getAsJsonObject(); + if (obj.has("success") && obj.get("success").isJsonPrimitive() && !obj.get("success").getAsBoolean()) { + errorCallback.onError(ErrorResponse.parseError(bodyStr)); + return; + } + if (obj.has("message") || obj.has("error") || obj.has("error_message")) { + errorCallback.onError(ErrorResponse.parseError(bodyStr)); + return; + } + } catch (Exception ignored) { + // not a JSON error payload — treat as file bytes + } + } + File out = new File(downloadPath); + File parent = out.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + try (FileOutputStream fos = new FileOutputStream(out)) { + fos.write(body); + } + ZBGetFileResponse response = new ZBGetFileResponse(); + response.setLocalFilePath(downloadPath); + successCallback.onSuccess(response); + } + + private boolean deliverMultipartTransport( + String url, + Map fields, + String fileName, + ZeroBounceSDK.OnSuccessCallback successCallback, + ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (sdk.transport == null) { + return false; + } + Map recorded = new LinkedHashMap<>(fields); + String key = sdk.priority3ApiKey(); + recorded.put("api_key", key == null ? "" : key); + recorded.put("file", fileName == null || fileName.isBlank() ? "" : fileName); + try { + SdkHttp.Result result = sdk.transport.exchange( + new SdkHttp("POST", url, "multipart/form-data", null, recorded)); + String rsp = new String(result.body, StandardCharsets.UTF_8); + if (!sdk.reportHttpFailure(result.status, rsp, errorCallback)) { + successCallback.onSuccess(sdk.priority3Gson().fromJson(rsp, ZBSendFileResponse.class)); + } + } catch (Exception e) { + errorCallback.onError(ErrorResponse.parseError(sdk.redactedFailure(e))); + } + return true; + } + + private void handleMultipartResponse( + CloseableHttpResponse response, + ZeroBounceSDK.OnSuccessCallback successCallback, + ZeroBounceSDK.OnErrorCallback errorCallback + ) throws IOException { + HttpEntity responseEntity = response.getEntity(); + int status = response.getStatusLine().getStatusCode(); + StringBuilder content = new StringBuilder(); + try (BufferedReader in = new BufferedReader(new InputStreamReader(responseEntity.getContent()))) { + String inputLine; + while ((inputLine = in.readLine()) != null) { + content.append(inputLine); + } + } + String rsp = content.toString(); + if (!sdk.reportHttpFailure(status, rsp, errorCallback)) { + Type type = new TypeToken() { + }.getType(); + ZBSendFileResponse sendFileResponse = sdk.priority3Gson().fromJson(rsp, type); + successCallback.onSuccess(sendFileResponse); + } + } +} diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/SdkHttp.java b/zero-bounce-sdk/src/main/java/com/zerobounce/SdkHttp.java new file mode 100644 index 0000000..96317e5 --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/SdkHttp.java @@ -0,0 +1,47 @@ +package com.zerobounce; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * One outbound call. {@link Transport} replaces the network for a single {@link ZeroBounceSDK}. + */ +final class SdkHttp { + + final String method; + final String url; + final String contentType; + final byte[] body; + final Map fields; + /** The real clients disable redirects. The offline suite asserts this stays false. */ + final boolean followRedirects; + + SdkHttp(String method, String url, String contentType, byte[] body, Map fields) { + this.method = method; + this.url = url; + this.contentType = contentType; + this.body = body == null ? new byte[0] : body; + this.fields = fields == null + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(fields)); + this.followRedirects = false; + } + + interface Transport { + Result exchange(SdkHttp call) throws IOException; + } + + static final class Result { + final int status; + final String contentType; + final byte[] body; + + Result(int status, String contentType, byte[] body) { + this.status = status; + this.contentType = contentType == null ? "" : contentType; + this.body = body == null ? new byte[0] : body; + } + } +} diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBS3ListFileEntry.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBS3ListFileEntry.java new file mode 100644 index 0000000..727f1a4 --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBS3ListFileEntry.java @@ -0,0 +1,27 @@ +package com.zerobounce; + +import com.google.gson.annotations.SerializedName; + +/** + * One file entry from GET /listS3files. + */ +public class ZBS3ListFileEntry { + + private String name; + private int size; + + @SerializedName("last_modified") + private String lastModified; + + public String getName() { + return name; + } + + public int getSize() { + return size; + } + + public String getLastModified() { + return lastModified; + } +} diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBScoreEmailResponse.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBScoreEmailResponse.java new file mode 100644 index 0000000..01434f2 --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBScoreEmailResponse.java @@ -0,0 +1,55 @@ +package com.zerobounce; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** + * Response for GET/POST /scoring (real-time AI scoring). + */ +public class ZBScoreEmailResponse { + + @Nullable + private String email = null; + + @Nullable + private Integer score = null; + + public @Nullable String getEmail() { + return email; + } + + public void setEmail(@Nullable String email) { + this.email = email; + } + + public @Nullable Integer getScore() { + return score; + } + + public void setScore(@Nullable Integer score) { + this.score = score; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ZBScoreEmailResponse that = (ZBScoreEmailResponse) o; + return Objects.equals(email, that.email) && Objects.equals(score, that.score); + } + + @Override + public int hashCode() { + return Objects.hash(email, score); + } + + @Override + public @NotNull String toString() { + return "ZBScoreEmailResponse{" + + "email='" + email + '\'' + + ", score=" + score + + '}'; + } +} diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateBatchOptions.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateBatchOptions.java new file mode 100644 index 0000000..d9e35d5 --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateBatchOptions.java @@ -0,0 +1,45 @@ +package com.zerobounce; + +import org.jetbrains.annotations.Nullable; + +/** + * Optional body fields for POST /validatebatch. + */ +public class ZBValidateBatchOptions { + + @Nullable + private Integer timeout; + + @Nullable + private Boolean activityData; + + @Nullable + private Boolean verifyPlus; + + @Nullable + public Integer getTimeout() { + return timeout; + } + + public void setTimeout(@Nullable Integer timeout) { + this.timeout = timeout; + } + + @Nullable + public Boolean getActivityData() { + return activityData; + } + + public void setActivityData(@Nullable Boolean activityData) { + this.activityData = activityData; + } + + @Nullable + public Boolean getVerifyPlus() { + return verifyPlus; + } + + public void setVerifyPlus(@Nullable Boolean verifyPlus) { + this.verifyPlus = verifyPlus; + } +} diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateRequestOptions.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateRequestOptions.java new file mode 100644 index 0000000..841bc01 --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateRequestOptions.java @@ -0,0 +1,67 @@ +package com.zerobounce; + +import org.jetbrains.annotations.Nullable; + +/** + * Optional query parameters for GET /validate. + */ +public class ZBValidateRequestOptions { + + @Nullable + private String ipAddress; + + @Nullable + private Integer timeout; + + @Nullable + private Boolean activityData; + + @Nullable + private Boolean verifyPlus; + + private boolean usePost; + + @Nullable + public String getIpAddress() { + return ipAddress; + } + + public void setIpAddress(@Nullable String ipAddress) { + this.ipAddress = ipAddress; + } + + @Nullable + public Integer getTimeout() { + return timeout; + } + + public void setTimeout(@Nullable Integer timeout) { + this.timeout = timeout; + } + + @Nullable + public Boolean getActivityData() { + return activityData; + } + + public void setActivityData(@Nullable Boolean activityData) { + this.activityData = activityData; + } + + @Nullable + public Boolean getVerifyPlus() { + return verifyPlus; + } + + public void setVerifyPlus(@Nullable Boolean verifyPlus) { + this.verifyPlus = verifyPlus; + } + + public boolean isUsePost() { + return usePost; + } + + public void setUsePost(boolean usePost) { + this.usePost = usePost; + } +} diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateResponse.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateResponse.java index c6a2432..2d6b54d 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateResponse.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateResponse.java @@ -42,6 +42,14 @@ public class ZBValidateResponse { @Nullable private String domainAgeDays = null; + @SerializedName("active_in_days") + @Nullable + private String activeInDays = null; + + @SerializedName("active_first_seen") + @Nullable + private String activeFirstSeen = null; + //@Nullable [true/false] If the email comes from a free provider. @SerializedName("free_email") @Nullable @@ -162,6 +170,22 @@ public void setDomainAgeDays(@Nullable String domainAgeDays) { this.domainAgeDays = domainAgeDays; } + public @Nullable String getActiveInDays() { + return activeInDays; + } + + public void setActiveInDays(@Nullable String activeInDays) { + this.activeInDays = activeInDays; + } + + public @Nullable String getActiveFirstSeen() { + return activeFirstSeen; + } + + public void setActiveFirstSeen(@Nullable String activeFirstSeen) { + this.activeFirstSeen = activeFirstSeen; + } + public @Nullable Boolean getFreeEmail() { return freeEmail; } @@ -286,6 +310,8 @@ public boolean equals(Object o) { && Objects.equals(domain, that.domain) && Objects.equals(didYouMean, that.didYouMean) && Objects.equals(domainAgeDays, that.domainAgeDays) + && Objects.equals(activeInDays, that.activeInDays) + && Objects.equals(activeFirstSeen, that.activeFirstSeen) && Objects.equals(freeEmail, that.freeEmail) && Objects.equals(mxFound, that.mxFound) && Objects.equals(mxRecord, that.mxRecord) @@ -304,7 +330,8 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(address, status, subStatus, account, domain, didYouMean, domainAgeDays, freeEmail, + return Objects.hash(address, status, subStatus, account, domain, didYouMean, domainAgeDays, + activeInDays, activeFirstSeen, freeEmail, mxFound, mxRecord, smtpProvider, catchallDomain, firstName, lastName, gender, city, region, zipCode, country, processedAt, error ); diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java index c6e19df..55c9560 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java @@ -82,13 +82,28 @@ public static void setLogPayloads(boolean enabled) { String apiBaseUrl = "https://api.zerobounce.net/v2"; private final String bulkApiBaseUrl = "https://bulkapi.zerobounce.net/v2"; private final String bulkApiScoringBaseUrl = "https://bulkapi.zerobounce.net/v2/scoring"; + private final String bulkApiRootUrl = "https://bulkapi.zerobounce.net"; + private final String emailFinderBulkBaseUrl = bulkApiRootUrl + "/email-finder"; + private final String domainSearchBulkBaseUrl = bulkApiRootUrl + "/domain-search"; + + private final Priority3Api priority3Api = new Priority3Api(this); private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.US); private String apiKey; private int timeoutInMillis; private final Gson gson; - private ZeroBounceSDK() { + /** + * When non-null, every HTTP call is answered by this transport instead of the network. + * Tests set it. Production leaves it null. + */ + SdkHttp.Transport transport; + + /** + * A client that holds one API key. Prefer this over {@link #getInstance()} when more than one + * key is in use: the singleton shares a single mutable key across the process. + */ + public ZeroBounceSDK() { GsonBuilder gsonBuilder = new GsonBuilder(); gsonBuilder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); gsonBuilder.registerTypeAdapter(ZBSendFileResponse.class, new ZBSendFileResponse.Deserializer()); @@ -140,6 +155,54 @@ static void requireHttpsUrl(String url) { } } + private String redactSecrets(@Nullable String message) { + if (message == null || apiKey == null || apiKey.isEmpty()) { + return message; + } + return message.replace(apiKey, "***"); + } + + org.apache.http.client.config.RequestConfig apacheRequestConfig() { + return org.apache.http.client.config.RequestConfig.custom() + .setRedirectsEnabled(false) + .setConnectTimeout(timeoutInMillis) + .setConnectionRequestTimeout(timeoutInMillis) + .setSocketTimeout(timeoutInMillis) + .build(); + } + + String redactedFailure(@NotNull Throwable exception) { + String message = exception.getMessage(); + if (message == null || message.isBlank()) { + return "Request failed."; + } + String redacted = redactSecrets(message); + if (redacted == null || redacted.isBlank()) { + return "Request failed."; + } + if (HttpMessages.looksLikeHtml(redacted, null)) { + return HttpMessages.jsonCallFailure(0, redacted, null); + } + if (redacted.length() > 1000) { + return "Request failed."; + } + String stripped = HttpMessages.stripPrefix(redacted); + return stripped.isEmpty() ? "Request failed." : stripped; + } + + boolean reportHttpFailure(int status, @Nullable String body, @NotNull OnErrorCallback errorCallback) { + String text = body == null ? "" : body; + if (HttpMessages.looksLikeHtml(text, null) || text.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError(HttpMessages.jsonCallFailure(status, text, null))); + return true; + } + if (status > 299) { + errorCallback.onError(ErrorResponse.parseError(text)); + return true; + } + return false; + } + /** * Validates the given [email]. * @@ -153,18 +216,59 @@ public void validate( @Nullable String ipAddress, @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback + ) { + ZBValidateRequestOptions options = new ZBValidateRequestOptions(); + options.setIpAddress(ipAddress); + validateWithOptions(email, options, successCallback, errorCallback); + } + + /** + * Validates the given email with optional v2 query parameters. + */ + public void validateWithOptions( + @NotNull String email, + @Nullable ZBValidateRequestOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback ) { if (invalidApiKey(errorCallback)) return; + if (isBlank(email)) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: email")); + return; + } + + HashMap params = new HashMap<>(); + params.put("api_key", apiKey); + params.put("email", email); + String ip = options != null && options.getIpAddress() != null ? options.getIpAddress() : ""; + params.put("ip_address", ip); + if (options != null) { + if (options.getTimeout() != null) { + params.put("timeout", String.valueOf(options.getTimeout())); + } + if (options.getActivityData() != null) { + params.put("activity_data", options.getActivityData() ? "true" : "false"); + } + if (options.getVerifyPlus() != null) { + params.put("verify_plus", options.getVerifyPlus() ? "true" : "false"); + } + } + + if (options != null && options.isUsePost()) { + sendFormPostRequest( + apiBaseUrl + "/validate", + params, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + return; + } sendRequest( apiBaseUrl + "/validate", - new HashMap<>() { - { - put("api_key", apiKey); - put("email", email); - put("ip_address", ipAddress != null ? ipAddress : ""); - } - }, + params, null, new TypeToken<>() { }, @@ -184,12 +288,32 @@ public void validateBatch( @NotNull List emails, @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback + ) { + validateBatchWithOptions(emails, null, successCallback, errorCallback); + } + + public void validateBatchWithOptions( + @NotNull List emails, + @Nullable ZBValidateBatchOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback ) { if (invalidApiKey(errorCallback)) return; Map body = new HashMap<>(); body.put("api_key", apiKey); body.put("email_batch", emails); + if (options != null) { + if (options.getTimeout() != null) { + body.put("timeout", options.getTimeout()); + } + if (options.getActivityData() != null) { + body.put("activity_data", options.getActivityData()); + } + if (options.getVerifyPlus() != null) { + body.put("verify_plus", options.getVerifyPlus()); + } + } sendRequest( apiBaseUrl + "/validatebatch", @@ -269,7 +393,7 @@ public void findEmail( if (invalidApiKey(errorCallback)) return; // Validate that at least one of companyName or domain is provided. - if ((companyName == null || companyName.isEmpty()) && (domain == null || domain.isEmpty())) { + if (isBlank(companyName) && isBlank(domain)) { errorCallback.onError(ErrorResponse.parseError("Either companyName or domain must be provided.")); return; } @@ -280,16 +404,16 @@ public void findEmail( { put("api_key", apiKey); put("first_name", firstName); - if (domain != null) { + if (!isBlank(domain)) { put("domain", domain); } - if (companyName != null) { + if (!isBlank(companyName)) { put("company_name", companyName); } - if (middleName != null) { + if (!isBlank(middleName)) { put("middle_name", middleName); } - if (lastName != null) { + if (!isBlank(lastName)) { put("last_name", lastName); } } @@ -321,7 +445,7 @@ public void findDomain( if (invalidApiKey(errorCallback)) return; // Validate that at least one of companyName or domain is provided. - if ((companyName == null || companyName.isEmpty()) && (domain == null || domain.isEmpty())) { + if (isBlank(companyName) && isBlank(domain)) { errorCallback.onError(ErrorResponse.parseError("Either companyName or domain must be provided.")); return; } @@ -331,10 +455,10 @@ public void findDomain( new HashMap<>() { { put("api_key", apiKey); - if (domain != null) { + if (!isBlank(domain)) { put("domain", domain); } - if (companyName != null) { + if (!isBlank(companyName)) { put("company_name", companyName); } } @@ -546,8 +670,15 @@ private void _sendFile( throw new ZBException("Index for emailAddressColumnIndex must start from 1."); } + if (transport != null) { + Map fields = sendFileMultipartFields(scoring, emailAddressColumnIndex, options, file.getName()); + deliverSendFileTransport(urlPath, fields, successCallback, errorCallback); + return; + } + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { HttpPost uploadFile = new HttpPost(urlPath); + uploadFile.setConfig(apacheRequestConfig()); MultipartEntityBuilder builder = MultipartEntityBuilder.create(); builder.addPart("api_key", new StringBody(apiKey, ContentType.TEXT_PLAIN)); @@ -626,9 +757,8 @@ private void _sendFile( logDebug("ZeroBounceSDK::sendFile rsp=" + rsp); - if (status > 299) { - ErrorResponse errorResponse = ErrorResponse.parseError(rsp); - errorCallback.onError(errorResponse); + if (reportHttpFailure(status, rsp, errorCallback)) { + return; } else { Type type = new TypeToken() { }.getType(); @@ -639,7 +769,7 @@ private void _sendFile( } catch (Exception e) { logError("ZeroBounceSDK::sendFile failed", e); - ErrorResponse errorResponse = ErrorResponse.parseError(e.getMessage()); + ErrorResponse errorResponse = ErrorResponse.parseError(redactedFailure(e)); errorCallback.onError(errorResponse); } } @@ -660,8 +790,15 @@ private void _sendFileStream( throw new ZBException("Index for emailAddressColumnIndex must start from 1."); } + if (transport != null) { + Map fields = sendFileMultipartFields(scoring, emailAddressColumnIndex, options, fileName); + deliverSendFileTransport(urlPath, fields, successCallback, errorCallback); + return; + } + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { HttpPost uploadFile = new HttpPost(urlPath); + uploadFile.setConfig(apacheRequestConfig()); MultipartEntityBuilder builder = MultipartEntityBuilder.create(); builder.addPart("api_key", new StringBody(apiKey, ContentType.TEXT_PLAIN)); @@ -740,9 +877,8 @@ private void _sendFileStream( logDebug("ZeroBounceSDK::sendFile rsp=" + rsp); - if (status > 299) { - ErrorResponse errorResponse = ErrorResponse.parseError(rsp); - errorCallback.onError(errorResponse); + if (reportHttpFailure(status, rsp, errorCallback)) { + return; } else { Type type = new TypeToken() { }.getType(); @@ -753,7 +889,7 @@ private void _sendFileStream( } catch (Exception e) { logError("ZeroBounceSDK::sendFile failed", e); - ErrorResponse errorResponse = ErrorResponse.parseError(e.getMessage()); + ErrorResponse errorResponse = ErrorResponse.parseError(redactedFailure(e)); errorCallback.onError(errorResponse); } } @@ -803,6 +939,10 @@ private void _fileStatus( @NotNull OnErrorCallback errorCallback ) { if (invalidApiKey(errorCallback)) return; + if (isBlank(fileId)) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: file_id")); + return; + } sendRequest( (scoring ? bulkApiScoringBaseUrl : bulkApiBaseUrl) + "/filestatus", @@ -962,51 +1102,26 @@ private void _getFile( parent.mkdirs(); } URI uri = buildGetFileUri(scoring, fileId, options); + if (transport != null) { + SdkHttp.Result result = transport.exchange(new SdkHttp("GET", uri.toString(), null, null, null)); + deliverGetFile(file, downloadPath, result.status, result.contentType, result.body, successCallback, errorCallback); + return; + } try (CloseableHttpClient client = HttpClients.createDefault()) { HttpGet downloadFile = new HttpGet(uri); + downloadFile.setConfig(apacheRequestConfig()); try (CloseableHttpResponse response = client.execute(downloadFile)) { int statusCode = response.getStatusLine().getStatusCode(); HttpEntity entity = response.getEntity(); Header ctHeader = response.getFirstHeader("Content-Type"); String contentTypeRaw = ctHeader != null ? ctHeader.getValue() : ""; - String mime = contentTypeRaw.split(";")[0].trim().toLowerCase(Locale.ROOT); - byte[] bodyBytes = entity != null ? EntityUtils.toByteArray(entity) : new byte[0]; - - if (ContentType.APPLICATION_JSON.getMimeType().equalsIgnoreCase(mime)) { - String json = new String(bodyBytes, StandardCharsets.UTF_8); - try { - JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); - if (getFileJsonIndicatesFailure(obj)) { - errorCallback.onError(ErrorResponse.parseError(json)); - return; - } - if (obj.has("success")) { - errorCallback.onError(ErrorResponse.parseError(json)); - return; - } - } catch (Exception parseEx) { - errorCallback.onError(ErrorResponse.parseError(json)); - return; - } - } - - if (statusCode > 299) { - errorCallback.onError(ErrorResponse.parseError(new String(bodyBytes, StandardCharsets.UTF_8))); - return; - } - - try (FileOutputStream outStream = new FileOutputStream(file)) { - outStream.write(bodyBytes); - } - ZBGetFileResponse rsp = new ZBGetFileResponse(); - rsp.setLocalFilePath(downloadPath); - successCallback.onSuccess(rsp); + deliverGetFile(file, downloadPath, statusCode, contentTypeRaw, bodyBytes, successCallback, errorCallback); } } } catch (Exception e) { logError("ZeroBounceSDK::getFile failed", e); - errorCallback.onError(ErrorResponse.parseError(e.getMessage())); + errorCallback.onError(ErrorResponse.parseError(redactedFailure(e))); } } @@ -1103,6 +1218,277 @@ public void getActivityData( ); } + /** + * Real-time AI scoring for a single email (GET /scoring). + */ + public void scoreEmail( + @NotNull String email, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + if (isBlank(email)) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: email")); + return; + } + + sendRequest( + apiBaseUrl + "/scoring", + new HashMap<>() { + { + put("api_key", apiKey); + put("email", email); + } + }, + null, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + } + + /** + * Real-time AI scoring for a single email (POST /scoring, form-encoded). + */ + public void scoreEmailPost( + @NotNull String email, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + if (isBlank(email)) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: email")); + return; + } + + sendFormPostRequest( + apiBaseUrl + "/scoring", + new HashMap<>() { + { + put("api_key", apiKey); + put("email", email); + } + }, + new TypeToken<>() { + }, + successCallback, + errorCallback + ); + } + + // Priority 3 — bulk email finder, domain search, list evaluator, filters, S3 + + public void sendEmailFinderFile( + @NotNull File file, + int domainColumn, + @Nullable EmailFinderSendFileOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.sendEmailFinderFile(file, domainColumn, options, successCallback, errorCallback); + } + + public void sendEmailFinderFileStream( + @NotNull InputStream inputStream, + @NotNull String fileName, + int domainColumn, + @Nullable EmailFinderSendFileOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.sendEmailFinderFileStream(inputStream, fileName, domainColumn, options, successCallback, errorCallback); + } + + public void emailFinderFileStatus( + @NotNull String fileId, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.bulkRootFileStatus(emailFinderBulkBaseUrl, fileId, successCallback, errorCallback); + } + + public void emailFinderGetFile( + @NotNull String fileId, + @NotNull String downloadPath, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.bulkRootGetFile(emailFinderBulkBaseUrl, fileId, downloadPath, successCallback, errorCallback); + } + + public void emailFinderDeleteFile( + @NotNull String fileId, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.bulkRootDeleteFile(emailFinderBulkBaseUrl, fileId, successCallback, errorCallback); + } + + public void sendDomainSearchFile( + @NotNull File file, + int domainColumn, + @Nullable DomainSearchSendFileOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.sendDomainSearchFile(file, domainColumn, options, successCallback, errorCallback); + } + + public void sendDomainSearchFileStream( + @NotNull InputStream inputStream, + @NotNull String fileName, + int domainColumn, + @Nullable DomainSearchSendFileOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.sendDomainSearchFileStream(inputStream, fileName, domainColumn, options, successCallback, errorCallback); + } + + public void domainSearchFileStatus( + @NotNull String fileId, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.bulkRootFileStatus(domainSearchBulkBaseUrl, fileId, successCallback, errorCallback); + } + + public void domainSearchGetFile( + @NotNull String fileId, + @NotNull String downloadPath, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.bulkRootGetFile(domainSearchBulkBaseUrl, fileId, downloadPath, successCallback, errorCallback); + } + + public void domainSearchDeleteFile( + @NotNull String fileId, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.bulkRootDeleteFile(domainSearchBulkBaseUrl, fileId, successCallback, errorCallback); + } + + public void sendListEvaluatorFile( + @NotNull File file, + int emailAddressColumn, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.sendListEvaluatorFile(file, emailAddressColumn, successCallback, errorCallback); + } + + public void sendListEvaluatorFileStream( + @NotNull InputStream inputStream, + @NotNull String fileName, + int emailAddressColumn, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.sendListEvaluatorFileStream(inputStream, fileName, emailAddressColumn, successCallback, errorCallback); + } + + public void getListEvaluatorResult( + @NotNull String fileId, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.getListEvaluatorResult(fileId, successCallback, errorCallback); + } + + public void sendEvaluatedFile( + @NotNull File file, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.sendEvaluatedFile(file, successCallback, errorCallback); + } + + public void addFilter( + @NotNull String rule, + @NotNull String target, + @NotNull String value, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.addFilter(rule, target, value, successCallback, errorCallback); + } + + public void listFilters( + boolean usePost, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.listFilters(usePost, successCallback, errorCallback); + } + + public void deleteFilter( + @NotNull String rule, + @NotNull String target, + @NotNull String value, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.deleteFilter(rule, target, value, successCallback, errorCallback); + } + + public void listS3Files( + @Nullable String bucket, + @Nullable String search, + @NotNull OnSuccessCallback> successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + new Priority3Api(this).listS3Files(bucket, search, successCallback, errorCallback); + } + + public void validateS3File( + @NotNull ValidateS3FileOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.validateS3File(options, successCallback, errorCallback); + } + + public void scoreS3File( + @NotNull ScoreS3FileOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.scoreS3File(options, successCallback, errorCallback); + } + + public void exportS3File( + @NotNull String fileId, + @NotNull String exportBucket, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + priority3Api.exportS3File(fileId, exportBucket, successCallback, errorCallback); + } + /** * Checks if the [apiKey] is invalid or not and if it is, then it throws an error through the provided * [errorCallback]. @@ -1111,7 +1497,7 @@ public void getActivityData( * @return **true** if the [apiKey] is null or **false** otherwise */ private boolean invalidApiKey(@NotNull OnErrorCallback errorCallback) { - if (apiKey == null) { + if (apiKey == null || apiKey.isBlank()) { ErrorResponse errorResponse = ErrorResponse.parseError( "ZeroBounce SDK is not initialized. Please call ZeroBounceSDK.initialize(apiKey) first" ); @@ -1121,6 +1507,10 @@ private boolean invalidApiKey(@NotNull OnErrorCallback errorCallback) { return false; } + private static boolean isBlank(@Nullable String value) { + return value == null || value.isBlank(); + } + /** * An interface that provides a single method for a success callback. */ @@ -1163,69 +1553,142 @@ private void sendRequest( } } - URL url = createUrlFrom(ub.toString()); - HttpURLConnection con = (HttpURLConnection) url.openConnection(); - con.setRequestProperty("Accept", "application/json"); -// con.setRequestProperty("Content-Type", "application/json"); - // Timeouts must be set before connect(); the POST branch connects explicitly below. - con.setConnectTimeout(timeoutInMillis); - con.setReadTimeout(timeoutInMillis); - - String httpMethod; - if (body == null) { - httpMethod = "GET"; - con.setRequestMethod("GET"); + String httpMethod = body == null ? "GET" : "POST"; + byte[] payload = body == null ? null : gson.toJson(body).getBytes(StandardCharsets.UTF_8); + String urlString = ub.toString(); + int status; + String rsp; + if (transport != null) { + SdkHttp.Result result = transport.exchange(new SdkHttp( + httpMethod, + urlString, + payload == null ? null : "application/json; charset=UTF-8", + payload, + null + )); + status = result.status; + rsp = new String(result.body, StandardCharsets.UTF_8); } else { - httpMethod = "POST"; - con.setRequestMethod("POST"); - con.setDoOutput(true); + URL url = createUrlFrom(urlString); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + con.setInstanceFollowRedirects(false); + con.setRequestProperty("Accept", "application/json"); + // Timeouts must be set before connect(); the POST branch connects explicitly below. + con.setConnectTimeout(timeoutInMillis); + con.setReadTimeout(timeoutInMillis); + + if (body == null) { + con.setRequestMethod("GET"); + } else { + con.setRequestMethod("POST"); + con.setDoOutput(true); + con.setFixedLengthStreamingMode(payload.length); + con.connect(); + try (OutputStream os = con.getOutputStream()) { + os.write(payload); + } + } - // Attach the body to the request - byte[] out = gson.toJson(body).getBytes(StandardCharsets.UTF_8); - int length = out.length; + logDebug("ZeroBounceSDK::sendRequest executing " + httpMethod + " " + urlPath); + status = con.getResponseCode(); + logDebug("ZeroBounceSDK::sendRequest status: " + status); + Reader streamReader; + if (status > 299) { + streamReader = new InputStreamReader(con.getErrorStream()); + } else { + streamReader = new InputStreamReader(con.getInputStream()); + } - con.setFixedLengthStreamingMode(length); - con.connect(); - try (OutputStream os = con.getOutputStream()) { - os.write(out); + StringBuilder content = new StringBuilder(); + try (BufferedReader in = new BufferedReader(streamReader)) { + String inputLine; + while ((inputLine = in.readLine()) != null) { + content.append(inputLine); + } } + con.disconnect(); + rsp = content.toString(); } - logDebug("ZeroBounceSDK::sendRequest executing " + httpMethod + " " + urlPath); - - int status = con.getResponseCode(); - logDebug("ZeroBounceSDK::sendRequest status: " + status); - Reader streamReader; - if (status > 299) { - streamReader = new InputStreamReader(con.getErrorStream()); - } else { - streamReader = new InputStreamReader(con.getInputStream()); - } + logPayload("ZeroBounceSDK::sendRequest rsp=" + rsp); + deliverJson(status, rsp, typeToken, successCallback, errorCallback); + } catch (Exception e) { + logError("ZeroBounceSDK::sendRequest failed", e); + ErrorResponse errorResponse = ErrorResponse.parseError(redactedFailure(e)); + errorCallback.onError(errorResponse); + } + } - StringBuilder content = new StringBuilder(); - try (BufferedReader in = new BufferedReader(streamReader)) { - String inputLine; - while ((inputLine = in.readLine()) != null) { - content.append(inputLine); + private void sendFormPostRequest( + @NotNull String urlPath, + @NotNull Map formFields, + @NotNull TypeToken typeToken, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + try { + logDebug("ZeroBounceSDK::sendFormPostRequest preparing request: " + urlPath); + StringBuilder formBody = new StringBuilder(); + for (Map.Entry entry : formFields.entrySet()) { + if (formBody.length() > 0) { + formBody.append('&'); } + formBody.append(java.net.URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)); + formBody.append('='); + formBody.append(java.net.URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); } + byte[] out = formBody.toString().getBytes(StandardCharsets.UTF_8); + int status; + String rsp; + if (transport != null) { + SdkHttp.Result result = transport.exchange(new SdkHttp( + "POST", + urlPath, + "application/x-www-form-urlencoded", + out, + null + )); + status = result.status; + rsp = new String(result.body, StandardCharsets.UTF_8); + } else { + URL url = createUrlFrom(urlPath); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + con.setInstanceFollowRedirects(false); + con.setRequestProperty("Accept", "application/json"); + con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + con.setConnectTimeout(timeoutInMillis); + con.setReadTimeout(timeoutInMillis); + con.setRequestMethod("POST"); + con.setDoOutput(true); + con.setFixedLengthStreamingMode(out.length); + con.connect(); + try (OutputStream os = con.getOutputStream()) { + os.write(out); + } - con.disconnect(); - String rsp = content.toString(); - - logPayload("ZeroBounceSDK::sendRequest rsp=" + rsp); + status = con.getResponseCode(); + Reader streamReader; + if (status > 299) { + streamReader = new InputStreamReader(con.getErrorStream()); + } else { + streamReader = new InputStreamReader(con.getInputStream()); + } - if (status > 299) { - ErrorResponse errorResponse = ErrorResponse.parseError(rsp); - errorCallback.onError(errorResponse); - } else { - T response = gson.fromJson(rsp, typeToken.getType()); - successCallback.onSuccess(response); + StringBuilder content = new StringBuilder(); + try (BufferedReader in = new BufferedReader(streamReader)) { + String inputLine; + while ((inputLine = in.readLine()) != null) { + content.append(inputLine); + } + } + con.disconnect(); + rsp = content.toString(); } + logPayload("ZeroBounceSDK::sendFormPostRequest rsp=" + rsp); + deliverJson(status, rsp, typeToken, successCallback, errorCallback); } catch (Exception e) { - logError("ZeroBounceSDK::sendRequest failed", e); - ErrorResponse errorResponse = ErrorResponse.parseError(e.getMessage()); - errorCallback.onError(errorResponse); + logError("ZeroBounceSDK::sendFormPostRequest failed", e); + errorCallback.onError(ErrorResponse.parseError(redactedFailure(e))); } } @@ -1239,6 +1702,142 @@ URL createUrlFrom(String url) throws MalformedURLException { return URI.create(url).toURL(); } + private void deliverJson( + int status, + String rsp, + TypeToken typeToken, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ) { + String accountError = HttpMessages.batchAccountError(rsp); + if (accountError != null) { + errorCallback.onError(ErrorResponse.parseError(accountError)); + return; + } + String failure = HttpMessages.jsonCallFailure(status, rsp, null); + if (failure == null) { + failure = HttpMessages.successPayloadError(rsp); + } + if (failure != null) { + String body = rsp == null ? "" : rsp; + if (HttpMessages.preferShortMessage(body)) { + errorCallback.onError(ErrorResponse.parseError(failure)); + } else { + errorCallback.onError(ErrorResponse.parseError(body)); + } + return; + } + T response = gson.fromJson(rsp, typeToken.getType()); + successCallback.onSuccess(response); + } + + private void deliverGetFile( + File file, + String downloadPath, + int statusCode, + String contentTypeRaw, + byte[] bodyBytes, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ) throws IOException { + String downloadError = HttpMessages.downloadFailure(statusCode, bodyBytes, contentTypeRaw); + if (downloadError != null) { + String text = new String(bodyBytes, StandardCharsets.UTF_8); + if (HttpMessages.preferShortMessage(text)) { + errorCallback.onError(ErrorResponse.parseError(downloadError)); + } else { + errorCallback.onError(ErrorResponse.parseError(text)); + } + return; + } + String mime = contentTypeRaw == null ? "" : contentTypeRaw.split(";")[0].trim().toLowerCase(Locale.ROOT); + if (ContentType.APPLICATION_JSON.getMimeType().equalsIgnoreCase(mime)) { + String json = new String(bodyBytes, StandardCharsets.UTF_8); + try { + JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); + if (getFileJsonIndicatesFailure(obj)) { + errorCallback.onError(ErrorResponse.parseError(json)); + return; + } + if (obj.has("success")) { + errorCallback.onError(ErrorResponse.parseError(json)); + return; + } + } catch (Exception parseEx) { + errorCallback.onError(ErrorResponse.parseError(json)); + return; + } + } + + if (statusCode > 299) { + errorCallback.onError(ErrorResponse.parseError(new String(bodyBytes, StandardCharsets.UTF_8))); + return; + } + + try (FileOutputStream outStream = new FileOutputStream(file)) { + outStream.write(bodyBytes); + } + ZBGetFileResponse rsp = new ZBGetFileResponse(); + rsp.setLocalFilePath(downloadPath); + successCallback.onSuccess(rsp); + } + + private Map sendFileMultipartFields( + boolean scoring, + int emailAddressColumnIndex, + @Nullable SendFileOptions options, + @NotNull String fileName + ) { + Map fields = new LinkedHashMap<>(); + fields.put("api_key", apiKey == null ? "" : apiKey); + fields.put("email_address_column", String.valueOf(emailAddressColumnIndex)); + fields.put("file", fileName); + if (options != null) { + if (options.returnUrl != null) { + fields.put("return_url", options.returnUrl); + } + if (options.firstNameColumn > 0) { + fields.put("first_name_column", String.valueOf(options.firstNameColumn)); + } + if (options.lastNameColumn > 0) { + fields.put("last_name_column", String.valueOf(options.lastNameColumn)); + } + if (options.genderColumn > 0) { + fields.put("gender_column", String.valueOf(options.genderColumn)); + } + if (options.ipAddressColumn > 0) { + fields.put("ip_address_column", String.valueOf(options.ipAddressColumn)); + } + if (options.hasHeaderRow != null) { + fields.put("has_header_row", String.valueOf(options.hasHeaderRow)); + } + if (options.removeDuplicate != null) { + fields.put("remove_duplicate", String.valueOf(options.removeDuplicate)); + } + if (!scoring && options.allowPhase2 != null) { + fields.put("allow_phase_2", Boolean.toString(options.allowPhase2)); + } + } + return fields; + } + + private void deliverSendFileTransport( + String urlPath, + Map fields, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ) { + try { + SdkHttp.Result result = transport.exchange(new SdkHttp("POST", urlPath, "multipart/form-data", null, fields)); + String rsp = new String(result.body, StandardCharsets.UTF_8); + if (!reportHttpFailure(result.status, rsp, errorCallback)) { + successCallback.onSuccess(gson.fromJson(rsp, ZBSendFileResponse.class)); + } + } catch (Exception e) { + errorCallback.onError(ErrorResponse.parseError(redactedFailure(e))); + } + } + private static void logDebug(String message) { logger.debug(message); } @@ -1351,4 +1950,201 @@ public ScoringSendFileOptions setRemoveDuplicate(@Nullable Boolean removeDuplica return this; } } + + public static class EmailFinderSendFileOptions { + int firstNameColumn; + int lastNameColumn; + int middleNameColumn; + int fullNameColumn; + @Nullable Boolean hasHeaderRow; + + public EmailFinderSendFileOptions setFirstNameColumn(int firstNameColumn) { + this.firstNameColumn = firstNameColumn; + return this; + } + + public EmailFinderSendFileOptions setLastNameColumn(int lastNameColumn) { + this.lastNameColumn = lastNameColumn; + return this; + } + + public EmailFinderSendFileOptions setMiddleNameColumn(int middleNameColumn) { + this.middleNameColumn = middleNameColumn; + return this; + } + + public EmailFinderSendFileOptions setFullNameColumn(int fullNameColumn) { + this.fullNameColumn = fullNameColumn; + return this; + } + + public EmailFinderSendFileOptions setHasHeaderRow(@Nullable Boolean hasHeaderRow) { + this.hasHeaderRow = hasHeaderRow; + return this; + } + } + + public static class DomainSearchSendFileOptions { + @Nullable Boolean hasHeaderRow; + + public DomainSearchSendFileOptions setHasHeaderRow(@Nullable Boolean hasHeaderRow) { + this.hasHeaderRow = hasHeaderRow; + return this; + } + } + + public static class ValidateS3FileOptions { + @NotNull String fileName = ""; + @Nullable Boolean hasHeaderRow; + @Nullable Boolean removeDuplicates; + @Nullable Boolean removeDuplicate; + @Nullable Integer emailAddressColumn; + @Nullable String returnUrl; + int firstNameColumn; + int lastNameColumn; + int genderColumn; + int ipAddressColumn; + @Nullable Boolean allowPhase2; + + public ValidateS3FileOptions setFileName(@NotNull String fileName) { + this.fileName = fileName; + return this; + } + + public ValidateS3FileOptions setHasHeaderRow(@Nullable Boolean hasHeaderRow) { + this.hasHeaderRow = hasHeaderRow; + return this; + } + + public ValidateS3FileOptions setRemoveDuplicates(@Nullable Boolean removeDuplicates) { + this.removeDuplicates = removeDuplicates; + return this; + } + + public ValidateS3FileOptions setRemoveDuplicate(@Nullable Boolean removeDuplicate) { + this.removeDuplicate = removeDuplicate; + return this; + } + + public ValidateS3FileOptions setEmailAddressColumn(@Nullable Integer emailAddressColumn) { + this.emailAddressColumn = emailAddressColumn; + return this; + } + + public ValidateS3FileOptions setReturnUrl(@Nullable String returnUrl) { + this.returnUrl = returnUrl; + return this; + } + + public ValidateS3FileOptions setFirstNameColumn(int firstNameColumn) { + this.firstNameColumn = firstNameColumn; + return this; + } + + public ValidateS3FileOptions setLastNameColumn(int lastNameColumn) { + this.lastNameColumn = lastNameColumn; + return this; + } + + public ValidateS3FileOptions setGenderColumn(int genderColumn) { + this.genderColumn = genderColumn; + return this; + } + + public ValidateS3FileOptions setIpAddressColumn(int ipAddressColumn) { + this.ipAddressColumn = ipAddressColumn; + return this; + } + + public ValidateS3FileOptions setAllowPhase2(@Nullable Boolean allowPhase2) { + this.allowPhase2 = allowPhase2; + return this; + } + } + + public static class ScoreS3FileOptions { + @NotNull String fileName = ""; + @Nullable Boolean hasHeaderRow; + @Nullable Boolean removeDuplicates; + @Nullable Boolean removeDuplicate; + @Nullable Integer emailAddressColumn; + + public ScoreS3FileOptions setFileName(@NotNull String fileName) { + this.fileName = fileName; + return this; + } + + public ScoreS3FileOptions setHasHeaderRow(@Nullable Boolean hasHeaderRow) { + this.hasHeaderRow = hasHeaderRow; + return this; + } + + public ScoreS3FileOptions setRemoveDuplicates(@Nullable Boolean removeDuplicates) { + this.removeDuplicates = removeDuplicates; + return this; + } + + public ScoreS3FileOptions setRemoveDuplicate(@Nullable Boolean removeDuplicate) { + this.removeDuplicate = removeDuplicate; + return this; + } + + public ScoreS3FileOptions setEmailAddressColumn(@Nullable Integer emailAddressColumn) { + this.emailAddressColumn = emailAddressColumn; + return this; + } + } + + String priority3ApiKey() { + return apiKey; + } + + String priority3ApiBaseUrl() { + return apiBaseUrl; + } + + String priority3BulkApiBaseUrl() { + return bulkApiBaseUrl; + } + + String priority3BulkApiRootUrl() { + return bulkApiRootUrl; + } + + String priority3EmailFinderBaseUrl() { + return emailFinderBulkBaseUrl; + } + + String priority3DomainSearchBaseUrl() { + return domainSearchBulkBaseUrl; + } + + int priority3TimeoutMillis() { + return timeoutInMillis; + } + + Gson priority3Gson() { + return gson; + } + + void priority3SendRequest( + @NotNull String urlPath, + @Nullable Map queryParameters, + @Nullable Map body, + @NotNull TypeToken typeToken, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + sendRequest(urlPath, queryParameters, body, typeToken, successCallback, errorCallback); + } + + void priority3SendFormPost( + @NotNull String urlPath, + @NotNull Map formFields, + @NotNull TypeToken typeToken, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + sendFormPostRequest(urlPath, formFields, typeToken, successCallback, errorCallback); + } } diff --git a/zero-bounce-sdk/src/test/java/com/zerobounce/FakeApi.java b/zero-bounce-sdk/src/test/java/com/zerobounce/FakeApi.java new file mode 100644 index 0000000..44818be --- /dev/null +++ b/zero-bounce-sdk/src/test/java/com/zerobounce/FakeApi.java @@ -0,0 +1,280 @@ +package com.zerobounce; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * In-process ZeroBounce API. Records each call and never opens a socket. + */ +final class FakeApi implements SdkHttp.Transport { + + static final class RecordedRequest { + final String method; + final String host; + final String path; + final String apiKey; + final String contentType; + final String url; + final String rawBody; + final Map fields; + final boolean followRedirects; + + RecordedRequest( + String method, + String host, + String path, + String apiKey, + String contentType, + String url, + String rawBody, + Map fields, + boolean followRedirects + ) { + this.method = method; + this.host = host; + this.path = path; + this.apiKey = apiKey; + this.contentType = contentType; + this.url = url; + this.rawBody = rawBody; + this.fields = fields; + this.followRedirects = followRedirects; + } + + String field(String name) { + return fields.get(name); + } + } + + private final List records = Collections.synchronizedList(new ArrayList<>()); + + List records() { + synchronized (records) { + return List.copyOf(records); + } + } + + void clear() { + records.clear(); + } + + @Override + public SdkHttp.Result exchange(SdkHttp call) throws IOException { + try { + Thread.sleep(ThreadLocalRandom.current().nextInt(5, 20)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted", e); + } + RecordedRequest recorded = record(call); + records.add(recorded); + String key = recorded.apiKey == null ? "" : recorded.apiKey; + if (key.startsWith("html403")) { + return html(403, key); + } + if (key.startsWith("html200")) { + return html(200, key); + } + if (key.startsWith("empty")) { + return new SdkHttp.Result(200, "application/json", new byte[0]); + } + if (key.startsWith("malformed")) { + return text(200, "application/json", "not-json{{{"); + } + if (key.startsWith("errprefix") || key.startsWith("double-error-")) { + return text(400, "text/plain", "Error: Error: boom"); + } + if (key.startsWith("cf-json-") || key.startsWith("cfjson")) { + return text(403, "application/json", """ + {"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."} + """); + } + if (key.startsWith("batchall") || (key.startsWith("badkey") && recorded.path.contains("validatebatch"))) { + return json(batchAll()); + } + if (key.startsWith("batchitem")) { + return json(batchItem()); + } + if (key.startsWith("badkey")) { + return json("{\"error\":\"Invalid API Key or your account ran out of credits\"}"); + } + if (key.startsWith("nocredits")) { + return json("{\"error\":\"Your account ran out of credits\"}"); + } + return success(recorded); + } + + private SdkHttp.Result success(RecordedRequest recorded) { + String path = recorded.path; + if (path.endsWith("/getfile")) { + String csv = "email,status\nok@example.com,valid\n"; + return text(200, "text/csv", csv); + } + if (path.endsWith("/listS3files")) { + return json("[{\"file_name\":\"emails.csv\",\"size\":12}]"); + } + if (path.contains("/filters/")) { + return json("[]"); + } + if (path.endsWith("/getcredits")) { + return json("{\"Credits\":" + quote(recorded.apiKey) + "}"); + } + if (path.endsWith("/scoring")) { + return json("{\"email\":\"person@example.com\",\"score\":9}"); + } + if (path.endsWith("/validatebatch")) { + return json(""" + {"email_batch":[{"address":"ok@example.com","status":"valid"}],"errors":[]} + """); + } + if (path.endsWith("/validate")) { + return json("{\"address\":\"person@example.com\",\"status\":\"valid\"}"); + } + if (path.contains("/sendfile") || path.contains("/deletefile") || path.contains("S3file") + || path.contains("listevaluator") || path.contains("send-evaluated-file")) { + return json("{\"success\":true,\"message\":\"ok\",\"file_id\":\"fid\",\"file_name\":\"f.csv\"}"); + } + if (path.endsWith("/filestatus")) { + return json("{\"success\":true,\"file_id\":\"fid\",\"file_status\":\"Complete\",\"complete_percentage\":\"100\"}"); + } + return json("{\"success\":true}"); + } + + private static String batchAll() { + return """ + {"email_batch":[],"errors":[{"email_address":"all","error":"Invalid API Key or your account ran out of credits"}]} + """; + } + + private static String batchItem() { + return """ + {"email_batch":[{"address":"ok@example.com","status":"valid"},{"address":"bad@example.com","status":"invalid"}],"errors":[{"email_address":"bad@example.com","error":"mailbox_not_found"}]} + """; + } + + private static SdkHttp.Result json(String body) { + return text(200, "application/json", body); + } + + private static SdkHttp.Result text(int status, String contentType, String body) { + return new SdkHttp.Result(status, contentType, body.getBytes(StandardCharsets.UTF_8)); + } + + private static SdkHttp.Result html(int status, String apiKey) { + return new SdkHttp.Result(status, "text/html; charset=UTF-8", accessDeniedPage(apiKey)); + } + + private static byte[] accessDeniedPage(String apiKey) { + StringBuilder page = new StringBuilder(1_500_000); + page.append("Access denied | zerobounce.net | Zero Bounce"); + page.append(""); + String block = "

blocked

"; + while (page.length() < 1_400_000) { + page.append(block); + } + page.append(""); + return page.toString().getBytes(StandardCharsets.UTF_8); + } + + private static String quote(String value) { + if (value == null) { + return "null"; + } + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private static RecordedRequest record(SdkHttp call) { + ParsedUrl parsed = parseUrl(call.url); + Map fields = new LinkedHashMap<>(parsed.query); + String raw = new String(call.body, StandardCharsets.UTF_8); + if (call.fields != null && !call.fields.isEmpty()) { + fields.putAll(call.fields); + } else if (!raw.isBlank() && raw.trim().charAt(0) == '{') { + flattenJson(raw.trim(), fields); + } else if (raw.contains("=")) { + parseQuery(raw, fields); + } + String apiKey = fields.get("api_key"); + return new RecordedRequest( + call.method, + parsed.host, + parsed.path, + apiKey, + call.contentType, + call.url, + raw, + Collections.unmodifiableMap(fields), + call.followRedirects + ); + } + + private static void flattenJson(String raw, Map fields) { + com.google.gson.JsonObject object = com.google.gson.JsonParser.parseString(raw).getAsJsonObject(); + for (Map.Entry entry : object.entrySet()) { + com.google.gson.JsonElement value = entry.getValue(); + if (value.isJsonNull()) { + fields.put(entry.getKey(), "null"); + } else if (value.isJsonPrimitive()) { + fields.put(entry.getKey(), value.getAsString()); + } else { + fields.put(entry.getKey(), value.toString()); + } + } + } + + private static final class ParsedUrl { + String host = ""; + String path = "/"; + Map query = new LinkedHashMap<>(); + } + + private static ParsedUrl parseUrl(String url) { + ParsedUrl parsed = new ParsedUrl(); + int hostStart = 0; + int scheme = url.indexOf("://"); + if (scheme >= 0) { + hostStart = scheme + 3; + } + int pathStart = url.indexOf('/', hostStart); + int queryStart = url.indexOf('?', hostStart); + if (pathStart < 0) { + parsed.host = queryStart < 0 ? url.substring(hostStart) : url.substring(hostStart, queryStart); + } else { + parsed.host = url.substring(hostStart, pathStart); + parsed.path = queryStart < 0 ? url.substring(pathStart) : url.substring(pathStart, queryStart); + } + if (queryStart >= 0) { + parseQuery(url.substring(queryStart + 1), parsed.query); + } + return parsed; + } + + private static void parseQuery(String query, Map fields) { + int start = 0; + while (start < query.length()) { + int amp = query.indexOf('&', start); + if (amp < 0) { + amp = query.length(); + } + String pair = query.substring(start, amp); + int eq = pair.indexOf('='); + if (eq < 0) { + fields.put(decode(pair), ""); + } else { + fields.put(decode(pair.substring(0, eq)), decode(pair.substring(eq + 1))); + } + start = amp + 1; + } + } + + private static String decode(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } +} diff --git a/zero-bounce-sdk/src/test/java/com/zerobounce/OfflineApiTest.java b/zero-bounce-sdk/src/test/java/com/zerobounce/OfflineApiTest.java new file mode 100644 index 0000000..4fb9cb9 --- /dev/null +++ b/zero-bounce-sdk/src/test/java/com/zerobounce/OfflineApiTest.java @@ -0,0 +1,739 @@ +package com.zerobounce; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.http.HttpEntity; +import org.apache.http.StatusLine; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OfflineApiTest { + + @TempDir + Path tempDir; + + @Test + void validateUsesGetAndOmitsUnsetOptions() { + Probe probe = probe("key-a"); + ZBValidateResponse response = probe.success( + (ok, err) -> probe.sdk.validate("person@example.com", null, ok, err) + ); + assertEquals("valid", response.getStatus().name().toLowerCase()); + FakeApi.RecordedRequest request = probe.only(); + assertEquals("GET", request.method); + assertEquals("api.zerobounce.net", request.host); + assertEquals("/v2/validate", request.path); + assertEquals("key-a", request.apiKey); + assertEquals("person@example.com", request.field("email")); + assertEquals("", request.field("ip_address")); + assertNull(request.field("timeout")); + assertNull(request.field("activity_data")); + assertNull(request.field("verify_plus")); + assertFalse(request.followRedirects); + } + + @Test + void validateSendsZeroTimeoutFalseFlagsAndPostForm() { + Probe probe = probe("key a/b"); + ZBValidateRequestOptions options = new ZBValidateRequestOptions(); + options.setIpAddress("203.0.113.5"); + options.setTimeout(0); + options.setActivityData(false); + options.setVerifyPlus(true); + options.setUsePost(true); + probe.success((ok, err) -> probe.sdk.validateWithOptions("a b+c@example.com", options, ok, err)); + FakeApi.RecordedRequest request = probe.only(); + assertEquals("POST", request.method); + assertEquals("/v2/validate", request.path); + assertEquals("api.zerobounce.net", request.host); + assertEquals("application/x-www-form-urlencoded", request.contentType); + assertEquals("key a/b", request.apiKey); + assertEquals("a b+c@example.com", request.field("email")); + assertEquals("0", request.field("timeout")); + assertEquals("false", request.field("activity_data")); + assertEquals("true", request.field("verify_plus")); + assertTrue(request.rawBody.contains("email=")); + assertFalse(request.rawBody.contains(" ")); + assertFalse(request.url.contains(" ")); + } + + @Test + void validateBatchStaysOnApiHostAndKeepsPartialErrors() { + Probe probe = probe("batchitem-key"); + ZBValidateBatchOptions options = new ZBValidateBatchOptions(); + options.setTimeout(0); + options.setActivityData(false); + options.setVerifyPlus(true); + ZBValidateBatchResponse response = probe.success((ok, err) -> probe.sdk.validateBatchWithOptions( + List.of(new ZBValidateBatchData("ok@example.com", null)), + options, + ok, + err + )); + assertEquals(2, response.getEmailBatch().size()); + assertEquals(1, response.getErrors().size()); + assertEquals("bad@example.com", String.valueOf(response.getErrors().get(0).get("email_address"))); + FakeApi.RecordedRequest request = probe.only(); + assertEquals("POST", request.method); + assertEquals("api.zerobounce.net", request.host); + assertEquals("/v2/validatebatch", request.path); + assertNotEquals("bulkapi.zerobounce.net", request.host); + JsonObject body = JsonParser.parseString(request.rawBody).getAsJsonObject(); + assertEquals(0, body.get("timeout").getAsInt()); + assertFalse(body.get("activity_data").getAsBoolean()); + assertTrue(body.get("verify_plus").getAsBoolean()); + assertFalse(body.has("ip_address")); + } + + @Test + void validateBatchAccountErrorIsAnError() { + Probe probe = probe("batchall-key"); + ErrorResponse error = probe.failure((ok, err) -> probe.sdk.validateBatch( + List.of(new ZBValidateBatchData("a@example.com", "203.0.113.8")), + ok, + err + )); + assertShort(error, "batchall-key"); + assertTrue(error.getErrors().stream().anyMatch(message -> message.contains("Invalid API Key"))); + } + + @Test + void accountLevelSuccessBodiesAreErrors() { + Probe invalid = probe("badkey-1"); + ErrorResponse invalidKey = invalid.failure((ok, err) -> invalid.sdk.getCredits(ok, err)); + assertShort(invalidKey, "badkey-1"); + assertTrue(invalidKey.getErrors().get(0).contains("Invalid API Key")); + + Probe credits = probe("nocredits-1"); + ErrorResponse noCredits = credits.failure((ok, err) -> credits.sdk.validate("a@example.com", "", ok, err)); + assertShort(noCredits, "nocredits-1"); + assertTrue(noCredits.getErrors().get(0).contains("credits")); + } + + @Test + void htmlEmptyAndMalformedBodiesStayShort() throws Exception { + Probe denied = probe("html403-secret"); + ErrorResponse accessDenied = denied.failure((ok, err) -> denied.sdk.getCredits(ok, err)); + assertShort(accessDenied, "html403-secret"); + assertTrue(accessDenied.getErrors().get(0).contains("Access denied")); + + Probe htmlOk = probe("html200-secret"); + Path download = tempDir.resolve("html.csv"); + ErrorResponse htmlFile = htmlOk.failure((ok, err) -> htmlOk.sdk.getFile("fid", download.toString(), ok, err)); + assertShort(htmlFile, "html200-secret"); + assertFalse(Files.exists(download)); + + Probe finder = probe("html200-finder"); + Path finderFile = tempDir.resolve("finder.csv"); + ErrorResponse finderError = finder.failure((ok, err) -> finder.sdk.emailFinderGetFile("fid", finderFile.toString(), ok, err)); + assertShort(finderError, "html200-finder"); + assertFalse(Files.exists(finderFile)); + + Probe domain = probe("html403-domain"); + Path domainFile = tempDir.resolve("domain.csv"); + ErrorResponse domainError = domain.failure((ok, err) -> domain.sdk.domainSearchGetFile("fid", domainFile.toString(), ok, err)); + assertShort(domainError, "html403-domain"); + assertFalse(Files.exists(domainFile)); + + Probe empty = probe("empty-key"); + ErrorResponse emptyError = empty.failure((ok, err) -> empty.sdk.scoreEmail("a@example.com", ok, err)); + assertShort(emptyError, "empty-key"); + assertTrue(emptyError.getErrors().get(0).toLowerCase().contains("empty")); + + Probe malformed = probe("malformed-key"); + ErrorResponse malformedError = malformed.failure((ok, err) -> malformed.sdk.getActivityData("a@example.com", ok, err)); + assertShort(malformedError, "malformed-key"); + assertFalse(malformedError.getErrors().get(0).contains("JsonSyntax")); + assertFalse(malformedError.getErrors().get(0).contains("IllegalStateException")); + + Probe prefixed = probe("errprefix-key"); + ErrorResponse prefixedError = prefixed.failure((ok, err) -> prefixed.sdk.getCredits(ok, err)); + assertEquals(List.of("boom"), prefixedError.getErrors()); + + Probe cloudflare = probe("cf-json-secret"); + ErrorResponse cloudflareError = cloudflare.failure((ok, err) -> cloudflare.sdk.getCredits(ok, err)); + assertShort(cloudflareError, "cf-json-secret"); + String cloudflareText = String.join("\n", cloudflareError.getErrors()); + assertTrue(cloudflareText.contains("Access denied")); + assertFalse(cloudflareText.contains("developers.cloudflare.com")); + } + + @Test + void findersRejectBlankInputAndOmitUnsetFields() { + Probe missing = probe("key"); + ErrorResponse error = missing.failure((ok, err) -> missing.sdk.findEmail("Ada", " ", "", null, null, ok, err)); + assertEquals(0, missing.api.records().size()); + assertFalse(error.getErrors().isEmpty()); + + Probe domain = probe("key"); + ErrorResponse domainError = domain.failure((ok, err) -> domain.sdk.findDomain(" ", null, ok, err)); + assertEquals(0, domain.api.records().size()); + assertFalse(domainError.getErrors().isEmpty()); + + Probe probe = probe("key"); + probe.success((ok, err) -> probe.sdk.findEmail("Ada", "", "Acme", null, "", ok, err)); + FakeApi.RecordedRequest request = probe.only(); + assertEquals("/v2/guessformat", request.path); + assertEquals("Ada", request.field("first_name")); + assertEquals("Acme", request.field("company_name")); + assertNull(request.field("domain")); + assertNull(request.field("middle_name")); + assertNull(request.field("last_name")); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.findDomain("example.com", null, ok, err)); + assertEquals("example.com", probe.only().field("domain")); + assertNull(probe.only().field("company_name")); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.guessFormat("example.com", null, null, "Lovelace", ok, err)); + FakeApi.RecordedRequest guess = probe.only(); + assertEquals("GET", guess.method); + assertEquals("/v2/guessformat", guess.path); + assertNull(guess.field("first_name")); + assertEquals("Lovelace", guess.field("last_name")); + } + + @Test + void usageCreditsActivityAndScoring() { + Probe probe = probe("usage-key"); + Date start = Date.from(LocalDate.of(2020, 1, 2).atStartOfDay(ZoneId.systemDefault()).toInstant()); + Date end = Date.from(LocalDate.of(2020, 1, 3).atStartOfDay(ZoneId.systemDefault()).toInstant()); + probe.success((ok, err) -> probe.sdk.getApiUsage(start, end, ok, err)); + FakeApi.RecordedRequest usage = probe.only(); + assertEquals("/v2/getapiusage", usage.path); + assertEquals("2020-01-02", usage.field("start_date")); + assertEquals("2020-01-03", usage.field("end_date")); + + probe.api.clear(); + ZBCreditsResponse credits = probe.success((ok, err) -> probe.sdk.getCredits(ok, err)); + assertEquals("usage-key", credits.getCredits()); + assertEquals("/v2/getcredits", probe.only().path); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.getActivityData("person@example.com", ok, err)); + assertEquals("/v2/activity", probe.only().path); + + probe.api.clear(); + ZBScoreEmailResponse scored = probe.success((ok, err) -> probe.sdk.scoreEmail("person@example.com", ok, err)); + assertEquals(9, scored.getScore()); + assertEquals("GET", probe.only().method); + assertEquals("/v2/scoring", probe.only().path); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.scoreEmailPost("person@example.com", ok, err)); + FakeApi.RecordedRequest posted = probe.only(); + assertEquals("POST", posted.method); + assertEquals("/v2/scoring", posted.path); + assertEquals("application/x-www-form-urlencoded", posted.contentType); + assertEquals("person@example.com", posted.field("email")); + } + + @Test + void bulkFileRoundTripAndBooleans() throws Exception { + Probe probe = probe("file-key"); + File csv = tempDir.resolve("emails.csv").toFile(); + Files.writeString(csv.toPath(), "email\na@example.com\n"); + ZeroBounceSDK.SendFileOptions options = new ZeroBounceSDK.SendFileOptions() + .setHasHeaderRow(false) + .setRemoveDuplicate(true) + .setAllowPhase2(false) + .setFirstNameColumn(0) + .setReturnUrl("https://example.com/hook"); + ZBSendFileResponse sent = probe.success((ok, err) -> { + try { + probe.sdk.sendFile(csv, 1, options, ok, err); + } catch (ZBException e) { + fail(e); + } + }); + assertEquals("fid", sent.getFileId()); + FakeApi.RecordedRequest upload = probe.only(); + assertEquals("POST", upload.method); + assertEquals("bulkapi.zerobounce.net", upload.host); + assertEquals("/v2/sendfile", upload.path); + assertEquals("multipart/form-data", upload.contentType); + assertEquals("1", upload.field("email_address_column")); + assertEquals("false", upload.field("has_header_row")); + assertEquals("true", upload.field("remove_duplicate")); + assertEquals("false", upload.field("allow_phase_2")); + assertNull(upload.field("first_name_column")); + assertEquals("emails.csv", upload.field("file")); + + probe.api.clear(); + assertThrows(ZBException.class, () -> probe.sdk.sendFile(csv, 0, null, r -> fail(), e -> fail())); + assertEquals(0, probe.api.records().size()); + + probe.api.clear(); + byte[] bytes = "email\nb@example.com\n".getBytes(StandardCharsets.UTF_8); + probe.success((ok, err) -> { + try { + probe.sdk.sendFile(new ByteArrayInputStream(bytes), "stream.csv", 2, null, ok, err); + } catch (ZBException e) { + fail(e); + } + }); + assertEquals("2", probe.only().field("email_address_column")); + assertEquals("stream.csv", probe.only().field("file")); + assertNull(probe.only().field("allow_phase_2")); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.fileStatus("fid", ok, err)); + assertEquals("/v2/filestatus", probe.only().path); + assertEquals("fid", probe.only().field("file_id")); + + probe.api.clear(); + Path out = tempDir.resolve("out.csv"); + ZBGetFileOptions downloadOptions = new ZBGetFileOptions() + .setDownloadType(ZBDownloadType.PHASE_1) + .setActivityData(false); + probe.success((ok, err) -> probe.sdk.getFile("fid", out.toString(), downloadOptions, ok, err)); + FakeApi.RecordedRequest download = probe.only(); + assertEquals("/v2/getfile", download.path); + assertEquals("phase_1", download.field("download_type")); + assertEquals("false", download.field("activity_data")); + assertEquals("email,status\nok@example.com,valid\n", Files.readString(out)); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.deleteFile("fid", ok, err)); + assertEquals("/v2/deletefile", probe.only().path); + } + + @Test + void scoringBulkOmitsValidationOnlyFields() throws Exception { + Probe probe = probe("score-file"); + File csv = tempDir.resolve("score.csv").toFile(); + Files.writeString(csv.toPath(), "email\na@example.com\n"); + ZeroBounceSDK.ScoringSendFileOptions options = new ZeroBounceSDK.ScoringSendFileOptions() + .setHasHeaderRow(false) + .setRemoveDuplicate(false); + probe.success((ok, err) -> { + try { + probe.sdk.scoringSendFile(csv, 1, options, ok, err); + } catch (ZBException e) { + fail(e); + } + }); + FakeApi.RecordedRequest upload = probe.only(); + assertEquals("/v2/scoring/sendfile", upload.path); + assertEquals("false", upload.field("has_header_row")); + assertEquals("false", upload.field("remove_duplicate")); + assertNull(upload.field("allow_phase_2")); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.scoringFileStatus("fid", ok, err)); + assertEquals("/v2/scoring/filestatus", probe.only().path); + + probe.api.clear(); + Path out = tempDir.resolve("score-out.csv"); + probe.success((ok, err) -> probe.sdk.scoringGetFile( + "fid", + out.toString(), + new ZBGetFileOptions().setDownloadType(ZBDownloadType.COMBINED).setActivityData(true), + ok, + err + )); + FakeApi.RecordedRequest download = probe.only(); + assertEquals("/v2/scoring/getfile", download.path); + assertEquals("combined", download.field("download_type")); + assertNull(download.field("activity_data")); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.scoringDeleteFile("fid", ok, err)); + assertEquals("/v2/scoring/deletefile", probe.only().path); + } + + @Test + void priority3EndpointsAndFalseHeaderFlag() throws Exception { + Probe probe = probe("p3"); + File csv = tempDir.resolve("people.csv").toFile(); + Files.writeString(csv.toPath(), "domain\nexample.com\n"); + ZeroBounceSDK.EmailFinderSendFileOptions finderOptions = new ZeroBounceSDK.EmailFinderSendFileOptions() + .setHasHeaderRow(false) + .setFirstNameColumn(0) + .setFullNameColumn(3); + probe.success((ok, err) -> probe.sdk.sendEmailFinderFile(csv, 1, finderOptions, ok, err)); + FakeApi.RecordedRequest finder = probe.only(); + assertEquals("bulkapi.zerobounce.net", finder.host); + assertEquals("/email-finder/sendfile", finder.path); + assertEquals("1", finder.field("domain_column")); + assertEquals("false", finder.field("has_header_row")); + assertNull(finder.field("first_name_column")); + assertEquals("3", finder.field("full_name_column")); + assertEquals("people.csv", finder.field("file")); + + probe.api.clear(); + InputStream stream = new ByteArrayInputStream("domain\nexample.com\n".getBytes(StandardCharsets.UTF_8)); + probe.success((ok, err) -> probe.sdk.sendEmailFinderFileStream(stream, "names.csv", 2, null, ok, err)); + assertEquals("names.csv", probe.only().field("file")); + assertNull(probe.only().field("has_header_row")); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.emailFinderFileStatus("fid", ok, err)); + assertEquals("/email-finder/filestatus", probe.only().path); + probe.api.clear(); + Path found = tempDir.resolve("found.csv"); + probe.success((ok, err) -> probe.sdk.emailFinderGetFile("fid", found.toString(), ok, err)); + assertEquals("/email-finder/getfile", probe.only().path); + assertTrue(Files.size(found) > 0); + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.emailFinderDeleteFile("fid", ok, err)); + assertEquals("/email-finder/deletefile", probe.only().path); + + probe.api.clear(); + ZeroBounceSDK.DomainSearchSendFileOptions domainOptions = new ZeroBounceSDK.DomainSearchSendFileOptions() + .setHasHeaderRow(false); + probe.success((ok, err) -> probe.sdk.sendDomainSearchFile(csv, 4, domainOptions, ok, err)); + FakeApi.RecordedRequest domain = probe.only(); + assertEquals("/domain-search/sendfile", domain.path); + assertEquals("4", domain.field("domain_column")); + assertEquals("false", domain.field("has_header_row")); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.sendDomainSearchFileStream( + new ByteArrayInputStream("d\n".getBytes(StandardCharsets.UTF_8)), + "domains.csv", + 1, + null, + ok, + err + )); + assertEquals("domains.csv", probe.only().field("file")); + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.domainSearchFileStatus("fid", ok, err)); + assertEquals("/domain-search/filestatus", probe.only().path); + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.domainSearchGetFile("fid", tempDir.resolve("d.csv").toString(), ok, err)); + assertEquals("/domain-search/getfile", probe.only().path); + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.domainSearchDeleteFile("fid", ok, err)); + assertEquals("/domain-search/deletefile", probe.only().path); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.sendListEvaluatorFile(csv, 1, ok, err)); + assertEquals("/v2/listevaluator/", probe.only().path); + assertEquals("1", probe.only().field("email_address_column")); + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.sendListEvaluatorFileStream( + new ByteArrayInputStream("e\n".getBytes(StandardCharsets.UTF_8)), + "list.csv", + 1, + ok, + err + )); + assertEquals("list.csv", probe.only().field("file")); + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.getListEvaluatorResult("fid", ok, err)); + assertEquals("/v2/listevaluator/fid/", probe.only().path); + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.sendEvaluatedFile(csv, ok, err)); + assertEquals("/v2/send-evaluated-file/", probe.only().path); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.addFilter("exclude", "email", "spam@", ok, err)); + FakeApi.RecordedRequest added = probe.only(); + assertEquals("POST", added.method); + assertEquals("/v2/filters/add", added.path); + assertEquals("exclude", added.field("rule")); + assertEquals("spam@", added.field("value")); + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.listFilters(false, ok, err)); + assertEquals("GET", probe.only().method); + assertEquals("/v2/filters/list", probe.only().path); + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.listFilters(true, ok, err)); + assertEquals("POST", probe.only().method); + assertEquals("application/x-www-form-urlencoded", probe.only().contentType); + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.deleteFilter("exclude", "email", "spam@", ok, err)); + assertEquals("/v2/filters/delete", probe.only().path); + } + + @Test + void s3SendsZeroEmailColumnAndOmitsUnsetColumns() { + Probe probe = probe("s3-key"); + probe.success((ok, err) -> probe.sdk.listS3Files(null, " ", ok, err)); + FakeApi.RecordedRequest listed = probe.only(); + assertEquals("bulkapi.zerobounce.net", listed.host); + assertEquals("/listS3files", listed.path); + assertNull(listed.field("bucket")); + assertNull(listed.field("search")); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.listS3Files("inbox", "csv", ok, err)); + assertEquals("inbox", probe.only().field("bucket")); + assertEquals("csv", probe.only().field("search")); + + probe.api.clear(); + ZeroBounceSDK.ValidateS3FileOptions validate = new ZeroBounceSDK.ValidateS3FileOptions() + .setFileName("emails.csv") + .setEmailAddressColumn(0) + .setHasHeaderRow(false) + .setAllowPhase2(false) + .setFirstNameColumn(0); + probe.success((ok, err) -> probe.sdk.validateS3File(validate, ok, err)); + FakeApi.RecordedRequest validation = probe.only(); + assertEquals("POST", validation.method); + assertEquals("/validateS3file", validation.path); + assertEquals("0", validation.field("email_address_column")); + assertEquals("false", validation.field("has_header_row")); + assertEquals("false", validation.field("allow_phase_2")); + assertNull(validation.field("first_name_column")); + + probe.api.clear(); + ZeroBounceSDK.ScoreS3FileOptions score = new ZeroBounceSDK.ScoreS3FileOptions() + .setFileName("emails.csv") + .setRemoveDuplicate(false); + probe.success((ok, err) -> probe.sdk.scoreS3File(score, ok, err)); + FakeApi.RecordedRequest scoring = probe.only(); + assertEquals("/scoreS3file", scoring.path); + assertEquals("false", scoring.field("remove_duplicate")); + assertNull(scoring.field("allow_phase_2")); + assertNull(scoring.field("email_address_column")); + + probe.api.clear(); + probe.success((ok, err) -> probe.sdk.exportS3File("fid", "bucket", ok, err)); + FakeApi.RecordedRequest exported = probe.only(); + assertEquals("/exportS3file", exported.path); + assertEquals("fid", exported.field("file_id")); + assertEquals("bucket", exported.field("export_bucket")); + } + + @Test + void invalidArgumentsDoNotSend() throws Exception { + Probe blankKey = probe(""); + ErrorResponse blank = blankKey.failure((ok, err) -> blankKey.sdk.getCredits(ok, err)); + assertEquals(0, blankKey.api.records().size()); + assertFalse(blank.getErrors().isEmpty()); + + ZeroBounceSDK uninitialized = new ZeroBounceSDK(); + uninitialized.transport = new FakeApi(); + AtomicReference initError = new AtomicReference<>(); + uninitialized.getCredits(response -> fail(String.valueOf(response)), initError::set); + assertNotNull(initError.get()); + assertEquals(0, ((FakeApi) uninitialized.transport).records().size()); + + Probe probe = probe("key"); + ErrorResponse email = probe.failure((ok, err) -> probe.sdk.validate(" ", null, ok, err)); + assertEquals(0, probe.api.records().size()); + assertFalse(email.getErrors().isEmpty()); + + ErrorResponse score = probe.failure((ok, err) -> probe.sdk.scoreEmailPost("", ok, err)); + assertEquals(0, probe.api.records().size()); + assertFalse(score.getErrors().isEmpty()); + + ErrorResponse status = probe.failure((ok, err) -> probe.sdk.fileStatus(" ", ok, err)); + assertEquals(0, probe.api.records().size()); + + File csv = tempDir.resolve("blank.csv").toFile(); + Files.writeString(csv.toPath(), "a\n"); + ErrorResponse finder = probe.failure((ok, err) -> probe.sdk.sendEmailFinderFile(csv, 0, null, ok, err)); + assertEquals(0, probe.api.records().size()); + assertFalse(finder.getErrors().isEmpty()); + + ErrorResponse filter = probe.failure((ok, err) -> probe.sdk.addFilter("", "email", "x", ok, err)); + assertEquals(0, probe.api.records().size()); + + ErrorResponse s3 = probe.failure((ok, err) -> probe.sdk.validateS3File( + new ZeroBounceSDK.ValidateS3FileOptions().setFileName(" "), + ok, + err + )); + assertEquals(0, probe.api.records().size()); + assertFalse(s3.getErrors().isEmpty()); + + ErrorResponse exported = probe.failure((ok, err) -> probe.sdk.exportS3File("fid", " ", ok, err)); + assertEquals(0, probe.api.records().size()); + } + + @Test + void separateClientsDoNotMixKeys() throws Exception { + Probe first = probe("key-one"); + Probe second = probe("key-two"); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + AtomicReference firstResponse = new AtomicReference<>(); + AtomicReference secondResponse = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + Thread one = new Thread(() -> runCredits(first, start, done, firstResponse, failure)); + Thread two = new Thread(() -> runCredits(second, start, done, secondResponse, failure)); + one.start(); + two.start(); + start.countDown(); + assertTrue(done.await(5, java.util.concurrent.TimeUnit.SECONDS)); + one.join(); + two.join(); + assertNull(failure.get()); + assertEquals("key-one", firstResponse.get().getCredits()); + assertEquals("key-two", secondResponse.get().getCredits()); + assertEquals("key-one", first.only().apiKey); + assertEquals("key-two", second.only().apiKey); + } + + @Test + void urlConnectionDoesNotFollowRedirects() throws Exception { + ZeroBounceSDK sdk = spy(new ZeroBounceSDK()); + sdk.initialize("key"); + URL url = mock(URL.class); + HttpURLConnection connection = mock(HttpURLConnection.class); + when(url.openConnection()).thenReturn(connection); + when(connection.getResponseCode()).thenReturn(200); + when(connection.getInputStream()).thenReturn(new ByteArrayInputStream("{\"Credits\":\"1\"}".getBytes(StandardCharsets.UTF_8))); + doReturn(url).when(sdk).createUrlFrom(anyString()); + sdk.getCredits(response -> assertEquals("1", response.getCredits()), error -> fail(error.getErrors().toString())); + verify(connection).setInstanceFollowRedirects(false); + } + + @Test + void apacheDownloadDoesNotFollowRedirects() throws Exception { + ZeroBounceSDK sdk = new ZeroBounceSDK(); + sdk.initialize("key"); + CloseableHttpClient client = mock(CloseableHttpClient.class); + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + HttpEntity entity = mock(HttpEntity.class); + StatusLine status = mock(StatusLine.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpUriRequest.class); + when(client.execute(captor.capture())).thenReturn(response); + when(response.getEntity()).thenReturn(entity); + when(response.getStatusLine()).thenReturn(status); + when(status.getStatusCode()).thenReturn(200); + when(response.getFirstHeader("Content-Type")).thenReturn(null); + when(entity.getContent()).thenReturn(new ByteArrayInputStream("email\n".getBytes(StandardCharsets.UTF_8))); + try (MockedStatic clients = Mockito.mockStatic(HttpClients.class)) { + clients.when(HttpClients::createDefault).thenReturn(client); + Path out = tempDir.resolve("apache.csv"); + sdk.getFile("fid", out.toString(), responseBody -> { + }, error -> fail(error.getErrors().toString())); + } + RequestConfig config = ((HttpRequestBase) captor.getValue()).getConfig(); + assertNotNull(config); + assertFalse(config.isRedirectsEnabled()); + } + + @Test + void validateResponseEqualityIncludesActivityFields() { + ZBValidateResponse left = new ZBValidateResponse(); + left.setAddress("a@example.com"); + left.setActiveInDays("5"); + left.setActiveFirstSeen("2024-01-01"); + ZBValidateResponse right = new ZBValidateResponse(); + right.setAddress("a@example.com"); + right.setActiveInDays("9"); + right.setActiveFirstSeen("2024-01-01"); + assertNotEquals(left, right); + assertNotEquals(left.hashCode(), right.hashCode()); + right.setActiveInDays("5"); + right.setActiveFirstSeen("2024-02-01"); + assertNotEquals(left, right); + } + + private static void runCredits( + Probe probe, + CountDownLatch start, + CountDownLatch done, + AtomicReference response, + AtomicReference failure + ) { + try { + start.await(); + response.set(probe.success((ok, err) -> probe.sdk.getCredits(ok, err))); + } catch (Throwable throwable) { + failure.set(throwable); + } finally { + done.countDown(); + } + } + + private static void assertShort(ErrorResponse error, String secret) { + assertNotNull(error); + assertFalse(error.getErrors().isEmpty()); + String text = String.join("\n", error.getErrors()); + assertTrue(text.length() < 400, text.substring(0, Math.min(text.length(), 120))); + String lower = text.toLowerCase(); + assertFalse(lower.contains("blocked

")); + assertFalse(text.contains(secret)); + assertFalse(text.contains("Error: Error:")); + assertFalse(text.contains("[Error]:")); + } + + private Probe probe(String apiKey) { + return new Probe(apiKey); + } + + private static final class Probe { + final ZeroBounceSDK sdk = new ZeroBounceSDK(); + final FakeApi api = new FakeApi(); + + Probe(String apiKey) { + sdk.transport = api; + sdk.initialize(apiKey); + } + + FakeApi.RecordedRequest only() { + assertEquals(1, api.records().size(), api.records().toString()); + return api.records().get(0); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + T success(Call call) { + AtomicReference value = new AtomicReference(); + AtomicReference error = new AtomicReference<>(); + call.run(value::set, error::set); + if (error.get() != null) { + fail(error.get().getErrors().toString()); + } + assertNotNull(value.get()); + return (T) value.get(); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + ErrorResponse failure(Call call) { + AtomicReference value = new AtomicReference(); + AtomicReference error = new AtomicReference<>(); + call.run(value::set, error::set); + assertNull(value.get()); + assertNotNull(error.get()); + return error.get(); + } + } + + private interface Call { + @SuppressWarnings("rawtypes") + void run(ZeroBounceSDK.OnSuccessCallback success, ZeroBounceSDK.OnErrorCallback error); + } +} diff --git a/zero-bounce-sdk/src/test/java/com/zerobounce/ZeroBounceSDKTest.java b/zero-bounce-sdk/src/test/java/com/zerobounce/ZeroBounceSDKTest.java index 2fe0727..bc16e88 100644 --- a/zero-bounce-sdk/src/test/java/com/zerobounce/ZeroBounceSDKTest.java +++ b/zero-bounce-sdk/src/test/java/com/zerobounce/ZeroBounceSDKTest.java @@ -262,7 +262,9 @@ public void validateBatchEmails_ReturnsError() throws Exception { } ] }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError( + "Invalid API Key or your account ran out of credits" + ); List emailsData = new ArrayList<>(); emailsData.add(new ZBValidateBatchData("valid@example.com", "1.1.1.1")); @@ -1265,6 +1267,45 @@ public void scoringDeleteFile_ReturnsError() throws Exception { ); } + @Test + public void listS3Files_ReturnsSuccess() throws Exception { + String responseJson = """ + [ + { + "name": "uploads/list.csv", + "size": 100, + "last_modified": "2024-01-01T00:00:00Z" + } + ]"""; + + String urlPath = getEncodedUrl( + "https://bulkapi.zerobounce.net/listS3files", + new HashMap<>() { + { + put("api_key", API_KEY); + put("bucket", "my-bucket"); + put("search", "list"); + } + } + ); + mockRequest( + urlPath, + 200, + responseJson, + "", + () -> zeroBounceSDK.listS3Files( + "my-bucket", + "list", + response -> { + assertEquals(1, response.size()); + assertEquals("uploads/list.csv", response.get(0).getName()); + assertEquals(100, response.get(0).getSize()); + }, + errorResponse -> fail(errorResponse.toString()) + ) + ); + } + @Test public void getActivityData_ReturnsSuccess() throws Exception { // Prepare mock response and add it to the server @@ -1298,6 +1339,61 @@ public void getActivityData_ReturnsSuccess() throws Exception { ); } + @Test + public void scoreEmail_ReturnsSuccess() throws Exception { + String responseJson = """ + { + "email": "mark@gmail.com", + "score": 10 + }"""; + ZBScoreEmailResponse expectedResponse = gson.fromJson(responseJson, ZBScoreEmailResponse.class); + + String email = "mark@gmail.com"; + String urlPath = getEncodedUrl( + "https://api.zerobounce.net/v2/scoring", + new HashMap<>() { + { + put("api_key", API_KEY); + put("email", email); + } + } + ); + mockRequest( + urlPath, + 200, + responseJson, + "", + () -> zeroBounceSDK.scoreEmail( + email, + response -> assertEquals(expectedResponse, response), + errorResponse -> fail(errorResponse.toString()) + ) + ); + } + + @Test + public void scoreEmailPost_ReturnsSuccess() throws Exception { + String responseJson = """ + { + "email": "mark@gmail.com", + "score": 10 + }"""; + ZBScoreEmailResponse expectedResponse = gson.fromJson(responseJson, ZBScoreEmailResponse.class); + + String email = "mark@gmail.com"; + mockRequest( + "https://api.zerobounce.net/v2/scoring", + 200, + responseJson, + "", + () -> zeroBounceSDK.scoreEmailPost( + email, + response -> assertEquals(expectedResponse, response), + errorResponse -> fail(errorResponse.toString()) + ) + ); + } + @Test public void getActivityData_ReturnsError() throws Exception { // Prepare mock response and add it to the server