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