From 21cf9f38ecf286946ab7f66064bd115ea2d95da2 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:19:33 +0400 Subject: [PATCH 1/7] feat: validate options, scoring, filters, list evaluator, IPT and the bulk jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports 407f0a8 and b0444f8 from the Python SDK. Everything below is documented at https://www.zerobounce.net/docs/; nothing undocumented was added. - A validate overload taking ZBValidateOptions: timeout, activity_data, verify_plus, domain_info, greylist_processing, callback_url, and usePost for the form-encoded POST /v2/validate. An unset option is not sent; booleans go on the wire as the strings true/false. callback_url is where the greylist retry posts its result, so it is rejected without greylist_processing, and when blank, before any request is sent. timeout must be 3-60 seconds. - ZBValidateResponse gains activeInDays, activeFirstSeen, domainWebsiteExists, domainRegistrantCompanyName and message. message is the API's "Message" key, returned instead of a result while a greylist retry is still running; it is part of equals/hashCode/toString like the rest. - Real-time AI scoring: scoreEmail (GET /v2/scoring) and its POST variant. - Account filters: addFilter, listFilters, deleteFilter. - getListEvaluatorResult, on the list evaluator's own base URL rather than a path built from the bulk one, with the file id URL-encoded as one segment. - The S3 bulk jobs (listS3Files, validateS3File, scoreS3File, exportS3File, emailFinderS3File, domainSearchS3File) and the SFTP/FTP ones (validateSFTPFile, scoreSFTPFile, emailFinderSFTPFile, domainSearchSFTPFile, exportSFTPFile). These are on the bulkapi host WITHOUT /v2, so they use a separate bulkApiRootUrl. - The Inbox Placement Tester: the API key goes on the query string, bodies are camelCase JSON, and unset fields are left out. Two bulk shapes differ from the rest and are easy to get wrong: - validateSFTPFile's file_name is optional; leaving it unset uses the input bucket from the account's settings. Every other job requires it. - exportSFTPFile sends export_path, not the export_bucket exportS3File sends. sendRequest could only do a GET or a JSON POST, so sendRequestWithMethod is new: it takes an explicit method and either form fields or a JSON body, which the bulk jobs, the filters and the Tester's PUT and DELETE all need. initialize gains a four-argument overload for the list evaluator and Tester base URLs. They are separate hosts, not paths built from the bulk base, so overriding apiBaseUrl alone would leave a test pointing at the live API. ErrorResponse.parseError already collects every value of an error body, so the Tester's Django-style {"detail": ...} surfaces without a special case; a test now pins that. AGENTS.md is new, and records the four base URLs, the two bulk shapes above, and how to run the tests (there is no mvn on the host and mvnw has no wrapper jar committed, so Maven runs in Docker). Tests: 58 pass, up from 46. Offline only — a mocked HttpURLConnection answers every call and records the url, method and body. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 31 + README.md | 128 +++ documentation.md | 129 +++ .../com/zerobounce/ZBBulkFileJobOptions.java | 87 ++ .../com/zerobounce/ZBS3ListFileEntry.java | 65 ++ .../com/zerobounce/ZBScoreEmailResponse.java | 76 ++ .../com/zerobounce/ZBValidateOptions.java | 146 ++++ .../com/zerobounce/ZBValidateResponse.java | 81 +- .../java/com/zerobounce/ZeroBounceSDK.java | 816 ++++++++++++++++++ .../java/com/zerobounce/OfflineApiTest.java | 565 ++++++++++++ 10 files changed, 2123 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 zero-bounce-sdk/src/main/java/com/zerobounce/ZBBulkFileJobOptions.java create mode 100644 zero-bounce-sdk/src/main/java/com/zerobounce/ZBS3ListFileEntry.java create mode 100644 zero-bounce-sdk/src/main/java/com/zerobounce/ZBScoreEmailResponse.java create mode 100644 zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateOptions.java create mode 100644 zero-bounce-sdk/src/test/java/com/zerobounce/OfflineApiTest.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..310b185 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Agent notes — zero-bounce-java-sdk-setup + +## Layout + +- `zero-bounce-sdk/` is the published Maven module. `src/` at the repo root is only the sample app. +- `ZeroBounceSDK.java` holds every request. `sendRequest` is the original GET/JSON-POST helper; `sendRequestWithMethod` is the one that can also do form-encoded POSTs and the Inbox Placement Tester's PUT and DELETE. +- `ZBValidateOptions`, `ZBBulkFileJobOptions`: the option objects, each with a package-private `fill` that both builds the request and rejects an invalid combination before anything is sent. +- `OfflineApiTest.java`: the offline suite. A mocked `HttpURLConnection` answers every call and records the url, method and body, so the assertions are on what actually went on the wire. + +## Build and test + +Maven runs in Docker (there is no `mvn` on the host, and `mvnw` has no wrapper jar committed): + +```sh +cd ../sdk-docs && docker compose run --rm --build java +``` + +That build runs `mvn -q test` and fails the build if a test fails — but the container's `CMD` then fails on `/root/.m2` permissions, which is not a test failure. To see the test output, or to run against the working tree without rebuilding: + +```sh +docker run --rm -u 0 -v "$PWD":/src -w /src/zero-bounce-sdk zerobounce-sdk-java:test mvn -B test +``` + +## Rules + +- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 and SFTP bulk endpoints live under https://www.zerobounce.net/docs/api-integrations, not the main API reference. +- **Four base URLs.** `apiBaseUrl`, `bulkApiBaseUrl` (ends in `/v2`), `bulkApiRootUrl` (the S3/SFTP jobs, **no** `/v2`), and `listEvaluatorBaseUrl` / `inboxPlacementBaseUrl`. The last two are separate hosts rather than paths built from the bulk base, so `initialize(apiKey, apiBaseUrl)` alone leaves them on the live API — use the four-argument `initialize` in tests. +- **Two bulk shapes are easy to get wrong.** `validateSFTPFile`'s `file_name` is optional — leaving it unset uses the input bucket from the account's settings, and every other job requires it. `exportSFTPFile` sends `export_path`, not the `export_bucket` that `exportS3File` sends. +- **`ErrorResponse.parseError` already handles `detail`.** It collects every value of the error JSON, so the Inbox Placement Tester's Django-style `{"detail": ...}` surfaces without a special case. A test pins that. +- **The version lives in the parent pom's ``.** Both poms read it; bumping one alone breaks the release. +- **Offline tests only.** Never call the live API from a test. diff --git a/README.md b/README.md index f16962b..9fefe80 100644 --- a/README.md +++ b/README.md @@ -539,6 +539,134 @@ Then you can use any of the SDK methods, for example: }); ``` +## Validate options + +`validate` has an overload that takes `ZBValidateOptions`. An option left unset +is not sent, and booleans go on the wire as the strings `"true"`/`"false"`. + +```java +ZBValidateOptions options = new ZBValidateOptions() + .setTimeout(30) // 3-60 seconds + .setActivityData(true) // fills getActiveInDays() / getActiveFirstSeen() + .setVerifyPlus(true) + .setDomainInfo(true) // fills getDomainWebsiteExists() / getDomainRegistrantCompanyName() + .setGreylistProcessing(true) + .setCallbackUrl("https://example.com/greylist") + .setUsePost(true); // form-encoded POST /v2/validate instead of GET + +ZeroBounceSDK.getInstance().validate("", null, options, + response -> System.out.println(response), + error -> System.out.println(error.getErrors())); +``` + +`setCallbackUrl` is where the greylist retry posts its result, so it is rejected +without `setGreylistProcessing(true)` — before any request is sent. While that +retry is still running the API answers with a `Message` instead of a result, +which lands on `response.getMessage()`. + +## Real-time AI scoring + +```java +ZeroBounceSDK.getInstance().scoreEmail("", onSuccess, onError); // GET /v2/scoring +ZeroBounceSDK.getInstance().scoreEmail("", true, onSuccess, onError); // form-encoded POST +``` + +## Filters + +```java +ZeroBounceSDK.getInstance().addFilter("exclude", "domain", "example.com", onSuccess, onError); +ZeroBounceSDK.getInstance().listFilters(false, onSuccess, onError); // true sends a POST +ZeroBounceSDK.getInstance().deleteFilter("exclude", "domain", "example.com", onSuccess, onError); +``` + +## S3 and SFTP/FTP bulk + +Queue a job on a file that already sits in your Amazon S3 bucket, or on your +SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api) +and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs. +These live on the bulkapi host *without* the `/v2` path. + +```java +ZeroBounceSDK zb = ZeroBounceSDK.getInstance(); + +// Amazon S3. The file name is the full path, as returned by listS3Files. +zb.listS3Files(null, null, onSuccess, onError); // optional bucket, search + +zb.validateS3File(new ZBBulkFileJobOptions() + .setFileName("lists/leads.csv") + .setHasHeaderRow(true), onSuccess, onError); +zb.scoreS3File(new ZBBulkFileJobOptions().setFileName("lists/leads.csv"), onSuccess, onError); +zb.emailFinderS3File(new ZBBulkFileJobOptions().setFileName("lists/leads.csv"), onSuccess, onError); +zb.domainSearchS3File(new ZBBulkFileJobOptions() + .setFileName("lists/leads.csv") + .setRemoveDuplicates(true) + .setReturnUrl("https://example.com/zerobounce-done"), onSuccess, onError); +zb.exportS3File("", "my-export-bucket", onSuccess, onError); + +// SFTP/FTP. The server, path and protocol come from your ZeroBounce settings. +// The file name may be left unset for validateSFTPFile alone, which then uses +// the input bucket from those settings. +zb.validateSFTPFile(new ZBBulkFileJobOptions().setHasHeaderRow(true), onSuccess, onError); +zb.scoreSFTPFile(new ZBBulkFileJobOptions().setFileName("my_file.txt"), onSuccess, onError); +zb.emailFinderSFTPFile(new ZBBulkFileJobOptions().setFileName("my_file.txt"), onSuccess, onError); +zb.domainSearchSFTPFile(new ZBBulkFileJobOptions().setFileName("my_file.txt"), onSuccess, onError); + +// Note the field: this one takes export_path, not export_bucket. +// Pass null to use the export path from your settings. +zb.exportSFTPFile("", "/exports", onSuccess, onError); +``` + +## List evaluator + +The list evaluator has its own base URL, not a path built from the bulk one, and +the file id is URL-encoded as a single path segment. + +```java +ZeroBounceSDK.getInstance().getListEvaluatorResult("", onSuccess, onError); +``` + +## Inbox Placement Tester + +Runs on its own host, takes the API key on the query string, and sends camelCase +JSON bodies. It spends the account's inbox-test credits. + +```java +ZeroBounceSDK zb = ZeroBounceSDK.getInstance(); + +zb.getInboxPlacementProviders(onSuccess, onError); +zb.getInboxPlacementTestTypes(onSuccess, onError); + +zb.createInboxPlacementTest("Launch", + new ZeroBounceSDK.CreateInboxPlacementTestOptions() + .setTestType(1) + .setSenderEmail("news@example.com") + .setWebhookUrl("https://example.com/ipt-webhook"), + onSuccess, onError); + +// Send your campaign to the seeds you got back, then read the results. +zb.getInboxPlacementTest("", onSuccess, onError); + +zb.listInboxPlacementTests( + new ZeroBounceSDK.ListInboxPlacementTestsOptions().setStatus("finished").setPage(1), + onSuccess, onError); +zb.setInboxPlacementWebhook("https://example.com/ipt-webhook", onSuccess, onError); +zb.getInboxPlacementWebhook(onSuccess, onError); +zb.deleteInboxPlacementTest("", onSuccess, onError); +``` + +## Per-API base URLs + +The list evaluator and the Inbox Placement Tester are separate hosts, so +overriding the main base URL alone leaves them pointing at the live API. + +```java +ZeroBounceSDK.getInstance().initialize( + "", + "https://api.zerobounce.net/v2", + "https://bulkapi.zerobounce.net/v2/listevaluator", + "https://bulkapi.zerobounce.net/api/ipt"); +``` + ## Breaking Changes: **`guessFormat`** has been deprecated. To continue using your existing code, you must migrate to **`findEmail`** or **`findDomain`** . The change is not a simple one-to-one replacement, as the functionality has been split: diff --git a/documentation.md b/documentation.md index db76b34..adcd3e3 100644 --- a/documentation.md +++ b/documentation.md @@ -479,3 +479,132 @@ You should then go to the [Nexus Sonatype](https://s01.oss.sonatype.org/), login gpg --list-keys gpg --list-secret-keys ``` + +## Validate options + +`validate` has an overload that takes `ZBValidateOptions`. An option left unset +is not sent, and booleans go on the wire as the strings `"true"`/`"false"`. + +```java +ZBValidateOptions options = new ZBValidateOptions() + .setTimeout(30) // 3-60 seconds + .setActivityData(true) // fills getActiveInDays() / getActiveFirstSeen() + .setVerifyPlus(true) + .setDomainInfo(true) // fills getDomainWebsiteExists() / getDomainRegistrantCompanyName() + .setGreylistProcessing(true) + .setCallbackUrl("https://example.com/greylist") + .setUsePost(true); // form-encoded POST /v2/validate instead of GET + +ZeroBounceSDK.getInstance().validate("", null, options, + response -> System.out.println(response), + error -> System.out.println(error.getErrors())); +``` + +`setCallbackUrl` is where the greylist retry posts its result, so it is rejected +without `setGreylistProcessing(true)` — before any request is sent. While that +retry is still running the API answers with a `Message` instead of a result, +which lands on `response.getMessage()`. + +## Real-time AI scoring + +```java +ZeroBounceSDK.getInstance().scoreEmail("", onSuccess, onError); // GET /v2/scoring +ZeroBounceSDK.getInstance().scoreEmail("", true, onSuccess, onError); // form-encoded POST +``` + +## Filters + +```java +ZeroBounceSDK.getInstance().addFilter("exclude", "domain", "example.com", onSuccess, onError); +ZeroBounceSDK.getInstance().listFilters(false, onSuccess, onError); // true sends a POST +ZeroBounceSDK.getInstance().deleteFilter("exclude", "domain", "example.com", onSuccess, onError); +``` + +## S3 and SFTP/FTP bulk + +Queue a job on a file that already sits in your Amazon S3 bucket, or on your +SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api) +and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs. +These live on the bulkapi host *without* the `/v2` path. + +```java +ZeroBounceSDK zb = ZeroBounceSDK.getInstance(); + +// Amazon S3. The file name is the full path, as returned by listS3Files. +zb.listS3Files(null, null, onSuccess, onError); // optional bucket, search + +zb.validateS3File(new ZBBulkFileJobOptions() + .setFileName("lists/leads.csv") + .setHasHeaderRow(true), onSuccess, onError); +zb.scoreS3File(new ZBBulkFileJobOptions().setFileName("lists/leads.csv"), onSuccess, onError); +zb.emailFinderS3File(new ZBBulkFileJobOptions().setFileName("lists/leads.csv"), onSuccess, onError); +zb.domainSearchS3File(new ZBBulkFileJobOptions() + .setFileName("lists/leads.csv") + .setRemoveDuplicates(true) + .setReturnUrl("https://example.com/zerobounce-done"), onSuccess, onError); +zb.exportS3File("", "my-export-bucket", onSuccess, onError); + +// SFTP/FTP. The server, path and protocol come from your ZeroBounce settings. +// The file name may be left unset for validateSFTPFile alone, which then uses +// the input bucket from those settings. +zb.validateSFTPFile(new ZBBulkFileJobOptions().setHasHeaderRow(true), onSuccess, onError); +zb.scoreSFTPFile(new ZBBulkFileJobOptions().setFileName("my_file.txt"), onSuccess, onError); +zb.emailFinderSFTPFile(new ZBBulkFileJobOptions().setFileName("my_file.txt"), onSuccess, onError); +zb.domainSearchSFTPFile(new ZBBulkFileJobOptions().setFileName("my_file.txt"), onSuccess, onError); + +// Note the field: this one takes export_path, not export_bucket. +// Pass null to use the export path from your settings. +zb.exportSFTPFile("", "/exports", onSuccess, onError); +``` + +## List evaluator + +The list evaluator has its own base URL, not a path built from the bulk one, and +the file id is URL-encoded as a single path segment. + +```java +ZeroBounceSDK.getInstance().getListEvaluatorResult("", onSuccess, onError); +``` + +## Inbox Placement Tester + +Runs on its own host, takes the API key on the query string, and sends camelCase +JSON bodies. It spends the account's inbox-test credits. + +```java +ZeroBounceSDK zb = ZeroBounceSDK.getInstance(); + +zb.getInboxPlacementProviders(onSuccess, onError); +zb.getInboxPlacementTestTypes(onSuccess, onError); + +zb.createInboxPlacementTest("Launch", + new ZeroBounceSDK.CreateInboxPlacementTestOptions() + .setTestType(1) + .setSenderEmail("news@example.com") + .setWebhookUrl("https://example.com/ipt-webhook"), + onSuccess, onError); + +// Send your campaign to the seeds you got back, then read the results. +zb.getInboxPlacementTest("", onSuccess, onError); + +zb.listInboxPlacementTests( + new ZeroBounceSDK.ListInboxPlacementTestsOptions().setStatus("finished").setPage(1), + onSuccess, onError); +zb.setInboxPlacementWebhook("https://example.com/ipt-webhook", onSuccess, onError); +zb.getInboxPlacementWebhook(onSuccess, onError); +zb.deleteInboxPlacementTest("", onSuccess, onError); +``` + +## Per-API base URLs + +The list evaluator and the Inbox Placement Tester are separate hosts, so +overriding the main base URL alone leaves them pointing at the live API. + +```java +ZeroBounceSDK.getInstance().initialize( + "", + "https://api.zerobounce.net/v2", + "https://bulkapi.zerobounce.net/v2/listevaluator", + "https://bulkapi.zerobounce.net/api/ipt"); +``` + diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBBulkFileJobOptions.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBBulkFileJobOptions.java new file mode 100644 index 0000000..5c0cf72 --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBBulkFileJobOptions.java @@ -0,0 +1,87 @@ +package com.zerobounce; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +/** + * The documented fields shared by the six S3 and SFTP/FTP file jobs. + *

+ * {@code fileName} is required by every job except + * {@link ZeroBounceSDK#validateSFTPFile}, where the docs list it as optional: + * leaving it null there uses the input bucket configured in your ZeroBounce + * settings. + */ +public class ZBBulkFileJobOptions { + + @Nullable + private String fileName = null; + + private boolean hasHeaderRow = true; + + @Nullable + private Boolean removeDuplicates = null; + + @Nullable + private String returnUrl = null; + + public ZBBulkFileJobOptions setFileName(@Nullable String fileName) { + this.fileName = fileName; + return this; + } + + public ZBBulkFileJobOptions setHasHeaderRow(boolean hasHeaderRow) { + this.hasHeaderRow = hasHeaderRow; + return this; + } + + public ZBBulkFileJobOptions setRemoveDuplicates(@Nullable Boolean removeDuplicates) { + this.removeDuplicates = removeDuplicates; + return this; + } + + public ZBBulkFileJobOptions setReturnUrl(@Nullable String returnUrl) { + this.returnUrl = returnUrl; + return this; + } + + public @Nullable String getFileName() { + return fileName; + } + + public boolean isHasHeaderRow() { + return hasHeaderRow; + } + + public @Nullable Boolean getRemoveDuplicates() { + return removeDuplicates; + } + + public @Nullable String getReturnUrl() { + return returnUrl; + } + + /** + * Adds the documented fields to the given form map. + * + * @param fileNameRequired false only for validateSFTPfile + * @throws IllegalArgumentException when a required field is missing or blank + */ + void fill(@NotNull Map fields, boolean fileNameRequired) { + boolean blank = fileName != null && fileName.trim().isEmpty(); + if ((fileName == null && fileNameRequired) || blank) { + throw new IllegalArgumentException("Empty parameter: file_name"); + } + fields.put("has_header_row", String.valueOf(hasHeaderRow)); + if (fileName != null) { + fields.put("file_name", fileName); + } + if (removeDuplicates != null) { + fields.put("remove_duplicates", String.valueOf(removeDuplicates)); + } + if (returnUrl != null) { + fields.put("return_url", returnUrl); + } + } +} 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..3ca25ad --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBS3ListFileEntry.java @@ -0,0 +1,65 @@ +package com.zerobounce; + +import com.google.gson.annotations.SerializedName; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** One entry of a listS3files response. */ +public class ZBS3ListFileEntry { + + @SerializedName("Name") + @Nullable + private String name = null; + + @SerializedName("Size") + private long size = 0; + + @SerializedName("LastModified") + @Nullable + private String lastModified = null; + + public @Nullable String getName() { + return name; + } + + public void setName(@Nullable String name) { + this.name = name; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + + public @Nullable String getLastModified() { + return lastModified; + } + + public void setLastModified(@Nullable String lastModified) { + this.lastModified = lastModified; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ZBS3ListFileEntry that = (ZBS3ListFileEntry) o; + return size == that.size + && Objects.equals(name, that.name) + && Objects.equals(lastModified, that.lastModified); + } + + @Override + public int hashCode() { + return Objects.hash(name, size, lastModified); + } + + @Override + public String toString() { + return "ZBS3ListFileEntry{name='" + name + "', size=" + size + ", lastModified='" + 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..96f14e2 --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBScoreEmailResponse.java @@ -0,0 +1,76 @@ +package com.zerobounce; + +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** The response of the real-time AI scoring endpoint (/v2/scoring). */ +public class ZBScoreEmailResponse { + + @Nullable + private String email = null; + + /** The API returns this as a number or a string depending on the account. */ + @Nullable + private String score = null; + + @Nullable + private String status = null; + + @Nullable + private String error = null; + + public @Nullable String getEmail() { + return email; + } + + public void setEmail(@Nullable String email) { + this.email = email; + } + + public @Nullable String getScore() { + return score; + } + + public void setScore(@Nullable String score) { + this.score = score; + } + + public @Nullable String getStatus() { + return status; + } + + public void setStatus(@Nullable String status) { + this.status = status; + } + + public @Nullable String getError() { + return error; + } + + public void setError(@Nullable String error) { + this.error = error; + } + + @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) + && Objects.equals(status, that.status) + && Objects.equals(error, that.error); + } + + @Override + public int hashCode() { + return Objects.hash(email, score, status, error); + } + + @Override + public String toString() { + return "ZBScoreEmailResponse{email='" + email + "', score='" + score + + "', status='" + status + "', error='" + error + "'}"; + } +} diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateOptions.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateOptions.java new file mode 100644 index 0000000..ab1f020 --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateOptions.java @@ -0,0 +1,146 @@ +package com.zerobounce; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +/** + * The documented optional parameters of single validation + * (docs). + *

+ * A field left null is not sent. Booleans go on the wire as the strings + * "true"/"false", which is what the API expects on both the GET and the form POST. + */ +public class ZBValidateOptions { + + /** Timeout in seconds. The API accepts 3 to 60. */ + @Nullable + private Integer timeout = null; + + /** Asks for the subscriber activity data block. */ + @Nullable + private Boolean activityData = null; + + /** Asks for the extended verification. */ + @Nullable + private Boolean verifyPlus = null; + + /** Asks for domain_website_exists and domain_registrant_company_name. */ + @Nullable + private Boolean domainInfo = null; + + /** Retries a greylisted address in the background. */ + @Nullable + private Boolean greylistProcessing = null; + + /** + * Where the greylist retry posts its result. It needs + * {@link #greylistProcessing} set to true and is rejected without it. + */ + @Nullable + private String callbackUrl = null; + + /** Sends a form-encoded POST /v2/validate instead of a GET. */ + private boolean usePost = false; + + public ZBValidateOptions setTimeout(@Nullable Integer timeout) { + this.timeout = timeout; + return this; + } + + public ZBValidateOptions setActivityData(@Nullable Boolean activityData) { + this.activityData = activityData; + return this; + } + + public ZBValidateOptions setVerifyPlus(@Nullable Boolean verifyPlus) { + this.verifyPlus = verifyPlus; + return this; + } + + public ZBValidateOptions setDomainInfo(@Nullable Boolean domainInfo) { + this.domainInfo = domainInfo; + return this; + } + + public ZBValidateOptions setGreylistProcessing(@Nullable Boolean greylistProcessing) { + this.greylistProcessing = greylistProcessing; + return this; + } + + public ZBValidateOptions setCallbackUrl(@Nullable String callbackUrl) { + this.callbackUrl = callbackUrl; + return this; + } + + public ZBValidateOptions setUsePost(boolean usePost) { + this.usePost = usePost; + return this; + } + + public @Nullable Integer getTimeout() { + return timeout; + } + + public @Nullable Boolean getActivityData() { + return activityData; + } + + public @Nullable Boolean getVerifyPlus() { + return verifyPlus; + } + + public @Nullable Boolean getDomainInfo() { + return domainInfo; + } + + public @Nullable Boolean getGreylistProcessing() { + return greylistProcessing; + } + + public @Nullable String getCallbackUrl() { + return callbackUrl; + } + + public boolean isUsePost() { + return usePost; + } + + /** + * Adds the options that are set to the given parameter map. + * + * @throws IllegalArgumentException if the options cannot produce a valid request + */ + void fill(@NotNull Map parameters) { + if (timeout != null) { + if (timeout < 3 || timeout > 60) { + throw new IllegalArgumentException("timeout must be between 3 and 60 seconds"); + } + parameters.put("timeout", String.valueOf(timeout)); + } + if (activityData != null) { + parameters.put("activity_data", String.valueOf(activityData)); + } + if (verifyPlus != null) { + parameters.put("verify_plus", String.valueOf(verifyPlus)); + } + if (domainInfo != null) { + parameters.put("domain_info", String.valueOf(domainInfo)); + } + if (greylistProcessing != null) { + parameters.put("greylist_processing", String.valueOf(greylistProcessing)); + } + if (callbackUrl != null) { + if (callbackUrl.trim().isEmpty()) { + throw new IllegalArgumentException("Empty parameter: callback_url"); + } + // The callback is where the greylist retry posts its result, so it is + // meaningless — and rejected by the API — without the retry itself. + if (!Boolean.TRUE.equals(greylistProcessing)) { + throw new IllegalArgumentException("callback_url requires greylist_processing to be true"); + } + parameters.put("callback_url", callbackUrl); + } + } +} 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..2eb2a39 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateResponse.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateResponse.java @@ -103,9 +103,77 @@ public class ZBValidateResponse { @Nullable private Date processedAt = null; + /** Returned with activity_data. */ + @SerializedName("active_in_days") + @Nullable + private String activeInDays = null; + + /** Returned with activity_data. */ + @SerializedName("active_first_seen") + @Nullable + private String activeFirstSeen = null; + + /** Returned with domain_info. */ + @SerializedName("domain_website_exists") + @Nullable + private String domainWebsiteExists = null; + + /** Returned with domain_info. */ + @SerializedName("domain_registrant_company_name") + @Nullable + private String domainRegistrantCompanyName = null; + + /** + * The API's "Message" key, returned instead of a result while a greylist + * retry is still running. + */ + @SerializedName("Message") + @Nullable + private String message = null; + @Nullable private String error = null; + 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 String getDomainWebsiteExists() { + return domainWebsiteExists; + } + + public void setDomainWebsiteExists(@Nullable String domainWebsiteExists) { + this.domainWebsiteExists = domainWebsiteExists; + } + + public @Nullable String getDomainRegistrantCompanyName() { + return domainRegistrantCompanyName; + } + + public void setDomainRegistrantCompanyName(@Nullable String domainRegistrantCompanyName) { + this.domainRegistrantCompanyName = domainRegistrantCompanyName; + } + + public @Nullable String getMessage() { + return message; + } + + public void setMessage(@Nullable String message) { + this.message = message; + } + public @Nullable String getAddress() { return address; } @@ -299,6 +367,11 @@ public boolean equals(Object o) { && Objects.equals(zipCode, that.zipCode) && Objects.equals(country, that.country) && Objects.equals(processedAt, that.processedAt) + && Objects.equals(activeInDays, that.activeInDays) + && Objects.equals(activeFirstSeen, that.activeFirstSeen) + && Objects.equals(domainWebsiteExists, that.domainWebsiteExists) + && Objects.equals(domainRegistrantCompanyName, that.domainRegistrantCompanyName) + && Objects.equals(message, that.message) && Objects.equals(error, that.error); } @@ -306,7 +379,8 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(address, status, subStatus, account, domain, didYouMean, domainAgeDays, freeEmail, mxFound, mxRecord, smtpProvider, catchallDomain, firstName, lastName, gender, city, region, zipCode, - country, processedAt, error + country, processedAt, activeInDays, activeFirstSeen, domainWebsiteExists, + domainRegistrantCompanyName, message, error ); } @@ -333,6 +407,11 @@ public String toString() { ", zipCode='" + zipCode + '\'' + ", country='" + country + '\'' + ", processedAt='" + processedAt + '\'' + + ", activeInDays='" + activeInDays + '\'' + + ", activeFirstSeen='" + activeFirstSeen + '\'' + + ", domainWebsiteExists='" + domainWebsiteExists + '\'' + + ", domainRegistrantCompanyName='" + domainRegistrantCompanyName + '\'' + + ", message='" + message + '\'' + ", error='" + 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..58dacd7 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java @@ -82,6 +82,18 @@ 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"; + /** + * The S3 and SFTP bulk jobs live on the bulkapi host WITHOUT the /v2 path. + * See https://www.zerobounce.net/docs/api-integrations. + */ + private final String bulkApiRootUrl = "https://bulkapi.zerobounce.net"; + /** + * The list evaluator and the Inbox Placement Tester have their own base URLs, + * not paths built from the bulk one. Both are overridable through + * {@link #initialize(String, String, String, String)}. + */ + String listEvaluatorBaseUrl = "https://bulkapi.zerobounce.net/v2/listevaluator"; + String inboxPlacementBaseUrl = "https://bulkapi.zerobounce.net/api/ipt"; private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.US); private String apiKey; @@ -134,6 +146,36 @@ public void initialize(String apiKey, @Nullable String apiBaseUrl) { } } + /** + * Initializes the SDK, overriding the list evaluator and Inbox Placement + * Tester base URLs as well. + *

+ * Those two are separate hosts rather than paths built from the bulk base, + * so an override of {@code apiBaseUrl} alone leaves them pointing at the + * live API. + * + * @param apiKey the API key + * @param apiBaseUrl the main API base URL, or null to keep the default + * @param listEvaluatorBaseUrl the list evaluator base URL, or null to keep the default + * @param inboxPlacementBaseUrl the Inbox Placement Tester base URL, or null to keep the default + */ + public void initialize( + String apiKey, + @Nullable String apiBaseUrl, + @Nullable String listEvaluatorBaseUrl, + @Nullable String inboxPlacementBaseUrl + ) { + initialize(apiKey, apiBaseUrl); + if (listEvaluatorBaseUrl != null) { + requireHttpsUrl(listEvaluatorBaseUrl); + this.listEvaluatorBaseUrl = listEvaluatorBaseUrl; + } + if (inboxPlacementBaseUrl != null) { + requireHttpsUrl(inboxPlacementBaseUrl); + this.inboxPlacementBaseUrl = inboxPlacementBaseUrl; + } + } + static void requireHttpsUrl(String url) { if (url == null || !url.regionMatches(true, 0, "https://", 0, 8)) { throw new IllegalArgumentException("apiBaseUrl must be an https:// URL"); @@ -1103,6 +1145,682 @@ public void getActivityData( ); } + // --------------------------------------------------------------------- + // Validate options, real-time AI scoring, filters + // --------------------------------------------------------------------- + + /** + * Validates the given email with the documented optional parameters. + *

+ * An option left null is not sent. With {@link ZBValidateOptions#setUsePost(boolean)} + * the same fields go out as a form-encoded POST instead of a query string. + * + * @param email the email address you want to validate + * @param ipAddress the IP Address the email signed up from (can be blank) + * @param options the optional parameters, or null for none + * @param successCallback the success callback + * @param errorCallback the error callback + */ + public void validate( + @NotNull String email, + @Nullable String ipAddress, + @Nullable ZBValidateOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + + Map parameters = new LinkedHashMap<>(); + parameters.put("api_key", apiKey); + parameters.put("email", email); + parameters.put("ip_address", ipAddress != null ? ipAddress : ""); + try { + if (options != null) { + options.fill(parameters); + } + } catch (IllegalArgumentException e) { + // Rejected before any request is sent. + errorCallback.onError(ErrorResponse.parseError(e.getMessage())); + return; + } + + boolean usePost = options != null && options.isUsePost(); + sendRequestWithMethod( + usePost ? "POST" : "GET", + apiBaseUrl + "/validate", + usePost ? null : parameters, + usePost ? parameters : null, + null, + new TypeToken() { + }, + successCallback, + errorCallback + ); + } + + /** + * Real-time AI scoring for one address (GET /v2/scoring). + */ + public void scoreEmail( + @NotNull String email, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + scoreEmail(email, false, successCallback, errorCallback); + } + + /** + * Real-time AI scoring for one address. + * + * @param usePost true sends the form-encoded POST /v2/scoring instead of the GET + */ + public void scoreEmail( + @NotNull String email, + boolean usePost, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + if (email.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: email")); + return; + } + + Map parameters = new LinkedHashMap<>(); + parameters.put("api_key", apiKey); + parameters.put("email", email); + + sendRequestWithMethod( + usePost ? "POST" : "GET", + apiBaseUrl + "/scoring", + usePost ? null : parameters, + usePost ? parameters : null, + null, + new TypeToken() { + }, + successCallback, + errorCallback + ); + } + + /** Adds an account filter (POST /v2/filters/add). */ + public void addFilter( + @NotNull String rule, + @NotNull String target, + @NotNull String value, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + filterRequest("/filters/add", rule, target, value, true, successCallback, errorCallback); + } + + /** Deletes an account filter (POST /v2/filters/delete). */ + public void deleteFilter( + @NotNull String rule, + @NotNull String target, + @NotNull String value, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + filterRequest("/filters/delete", rule, target, value, true, successCallback, errorCallback); + } + + /** + * Lists the account filters (/v2/filters/list). + * + * @param usePost true sends a POST instead of a GET; both are documented + */ + public void listFilters( + boolean usePost, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + filterRequest("/filters/list", null, null, null, usePost, successCallback, errorCallback); + } + + private void filterRequest( + @NotNull String path, + @Nullable String rule, + @Nullable String target, + @Nullable String value, + boolean usePost, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + + Map parameters = new LinkedHashMap<>(); + parameters.put("api_key", apiKey); + if (rule != null) parameters.put("rule", rule); + if (target != null) parameters.put("target", target); + if (value != null) parameters.put("value", value); + + sendRequestWithMethod( + usePost ? "POST" : "GET", + apiBaseUrl + path, + usePost ? null : parameters, + usePost ? parameters : null, + null, + new TypeToken() { + }, + successCallback, + errorCallback + ); + } + + // --------------------------------------------------------------------- + // S3 and SFTP/FTP bulk + // https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api + // https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api + // --------------------------------------------------------------------- + + /** + * Lists the files in your Amazon S3 bucket (GET /listS3files). + * + * @param bucket an optional bucket filter, or null + * @param search an optional name filter, or null + */ + public void listS3Files( + @Nullable String bucket, + @Nullable String search, + @NotNull OnSuccessCallback> successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + + Map parameters = new LinkedHashMap<>(); + parameters.put("api_key", apiKey); + if (bucket != null && !bucket.trim().isEmpty()) parameters.put("bucket", bucket); + if (search != null && !search.trim().isEmpty()) parameters.put("search", search); + + sendRequestWithMethod( + "GET", + bulkApiRootUrl + "/listS3files", + parameters, + null, + null, + new TypeToken() { + }, + payload -> successCallback.onSuccess(parseS3FileList(payload)), + errorCallback + ); + } + + /** + * listS3files is documented as {"success": true, "files": [...]} but also + * answers with a bare array, so both shapes are accepted. + */ + private List parseS3FileList(@Nullable JsonElement payload) { + Type listType = new TypeToken>() { + }.getType(); + if (payload == null || payload.isJsonNull()) { + return new ArrayList<>(); + } + if (payload.isJsonArray()) { + return gson.fromJson(payload, listType); + } + if (payload.isJsonObject() && payload.getAsJsonObject().has("files")) { + return gson.fromJson(payload.getAsJsonObject().get("files"), listType); + } + return new ArrayList<>(); + } + + /** Validates a file in your Amazon S3 bucket (POST /validateS3file). */ + public void validateS3File( + @NotNull ZBBulkFileJobOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + bulkRootFileJob("/validateS3file", options, true, successCallback, errorCallback); + } + + /** Scores a file in your Amazon S3 bucket (POST /scoreS3file). */ + public void scoreS3File( + @NotNull ZBBulkFileJobOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + bulkRootFileJob("/scoreS3file", options, true, successCallback, errorCallback); + } + + /** Runs the email finder on a file in your Amazon S3 bucket (POST /emailFinderS3file). */ + public void emailFinderS3File( + @NotNull ZBBulkFileJobOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + bulkRootFileJob("/emailFinderS3file", options, true, successCallback, errorCallback); + } + + /** Runs domain search on a file in your Amazon S3 bucket (POST /domainSearchS3file). */ + public void domainSearchS3File( + @NotNull ZBBulkFileJobOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + bulkRootFileJob("/domainSearchS3file", options, true, successCallback, errorCallback); + } + + /** + * Exports a validated or scored file back to Amazon S3 (POST /exportS3file). + * + * @param exportBucket the destination bucket, or null to use the one from + * your ZeroBounce settings + */ + public void exportS3File( + @NotNull String fileId, + @Nullable String exportBucket, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + exportBulkFile("/exportS3file", "export_bucket", fileId, exportBucket, successCallback, errorCallback); + } + + // SFTP / FTP bulk. The server, path and protocol are never sent by the SDK; + // they come from your ZeroBounce settings. + + /** + * Validates a file from your SFTP/FTP server (POST /validateSFTPfile). + *

+ * This is the one job whose file_name the docs list as optional: leave + * {@link ZBBulkFileJobOptions#setFileName(String)} unset to use the input + * bucket from your settings. + */ + public void validateSFTPFile( + @NotNull ZBBulkFileJobOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + bulkRootFileJob("/validateSFTPfile", options, false, successCallback, errorCallback); + } + + /** Scores a file from your SFTP/FTP server (POST /scoreSFTPfile). */ + public void scoreSFTPFile( + @NotNull ZBBulkFileJobOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + bulkRootFileJob("/scoreSFTPfile", options, true, successCallback, errorCallback); + } + + /** Runs the email finder on a file from your SFTP/FTP server (POST /emailFinderSFTPfile). */ + public void emailFinderSFTPFile( + @NotNull ZBBulkFileJobOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + bulkRootFileJob("/emailFinderSFTPfile", options, true, successCallback, errorCallback); + } + + /** Runs domain search on a file from your SFTP/FTP server (POST /domainSearchSFTPfile). */ + public void domainSearchSFTPFile( + @NotNull ZBBulkFileJobOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + bulkRootFileJob("/domainSearchSFTPfile", options, true, successCallback, errorCallback); + } + + /** + * Exports a validated or scored file back to your SFTP/FTP server + * (POST /exportSFTPfile). + *

+ * Note the field name: this endpoint takes export_path, not the + * export_bucket that {@link #exportS3File} sends. + * + * @param exportPath the destination path, or null to use the one from your + * ZeroBounce settings + */ + public void exportSFTPFile( + @NotNull String fileId, + @Nullable String exportPath, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + exportBulkFile("/exportSFTPfile", "export_path", fileId, exportPath, successCallback, errorCallback); + } + + /** POSTs one of the six documented S3 / SFTP file jobs on the bulkapi root. */ + private void bulkRootFileJob( + @NotNull String path, + @NotNull ZBBulkFileJobOptions options, + boolean fileNameRequired, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + + Map fields = new LinkedHashMap<>(); + try { + options.fill(fields, fileNameRequired); + } catch (IllegalArgumentException e) { + errorCallback.onError(ErrorResponse.parseError(e.getMessage())); + return; + } + fields.put("api_key", apiKey); + + sendRequestWithMethod( + "POST", + bulkApiRootUrl + path, + null, + fields, + null, + new TypeToken() { + }, + successCallback, + errorCallback + ); + } + + private void exportBulkFile( + @NotNull String path, + @NotNull String destinationField, + @NotNull String fileId, + @Nullable String destination, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + if (fileId.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: file_id")); + return; + } + if (destination != null && destination.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: " + destinationField)); + return; + } + + Map fields = new LinkedHashMap<>(); + fields.put("file_id", fileId); + if (destination != null) { + fields.put(destinationField, destination); + } + fields.put("api_key", apiKey); + + sendRequestWithMethod( + "POST", + bulkApiRootUrl + path, + null, + fields, + null, + new TypeToken() { + }, + successCallback, + errorCallback + ); + } + + // --------------------------------------------------------------------- + // List evaluator. Its own base URL, not a path built from the bulk one. + // --------------------------------------------------------------------- + + /** + * The evaluation of an uploaded file (GET /v2/listevaluator/{file_id}/). + *

+ * The file id is URL-encoded as a single path segment. + */ + public void getListEvaluatorResult( + @NotNull String fileId, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + if (fileId.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: file_id")); + return; + } + + Map parameters = new LinkedHashMap<>(); + parameters.put("api_key", apiKey); + + sendRequestWithMethod( + "GET", + listEvaluatorBaseUrl + "/" + encodePathSegment(fileId.trim()) + "/", + parameters, + null, + null, + new TypeToken() { + }, + successCallback, + errorCallback + ); + } + + /** URL-encodes a value as a single path segment, so a slash in it stays encoded. */ + private static String encodePathSegment(String value) { + return urlEncode(value).replace("+", "%20"); + } + + // --------------------------------------------------------------------- + // Inbox Placement Tester (bulkapi.zerobounce.net/api/ipt). + // The API key goes on the query string, bodies are camelCase JSON, and + // errors come back under a "detail" key (Django REST framework). + // Spends the account's inbox-test credits. + // --------------------------------------------------------------------- + + /** Seed providers available for new tests (GET /api/ipt/providers). */ + public void getInboxPlacementProviders( + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + inboxPlacementRequest("GET", "/providers", null, null, successCallback, errorCallback); + } + + /** Supported test types and the default one (GET /api/ipt/test-types). */ + public void getInboxPlacementTestTypes( + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + inboxPlacementRequest("GET", "/test-types", null, null, successCallback, errorCallback); + } + + /** + * Starts a test and returns its seed list (POST /api/ipt/test). + * + * @param name the test name, required + * @param options the optional fields, or null. Only the fields that are set are sent. + */ + public void createInboxPlacementTest( + @NotNull String name, + @Nullable CreateInboxPlacementTestOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (name.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: name")); + return; + } + Map body = new LinkedHashMap<>(); + body.put("name", name); + if (options != null) { + options.fill(body); + } + inboxPlacementRequest("POST", "/test", null, body, successCallback, errorCallback); + } + + /** The results of one test (GET /api/ipt/test/{id}). */ + public void getInboxPlacementTest( + @NotNull String testId, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + String segment = inboxPlacementTestPath(testId, errorCallback); + if (segment == null) return; + inboxPlacementRequest("GET", "/test/" + segment, null, null, successCallback, errorCallback); + } + + /** The account's tests (GET /api/ipt/test). */ + public void listInboxPlacementTests( + @Nullable ListInboxPlacementTestsOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + Map query = new LinkedHashMap<>(); + if (options != null) { + options.fill(query); + } + inboxPlacementRequest("GET", "/test", query, null, successCallback, errorCallback); + } + + /** Deletes one test (DELETE /api/ipt/test/{id}). */ + public void deleteInboxPlacementTest( + @NotNull String testId, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + String segment = inboxPlacementTestPath(testId, errorCallback); + if (segment == null) return; + inboxPlacementRequest("DELETE", "/test/" + segment, null, null, successCallback, errorCallback); + } + + /** The configured webhook (GET /api/ipt/webhook). */ + public void getInboxPlacementWebhook( + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + inboxPlacementRequest("GET", "/webhook", null, null, successCallback, errorCallback); + } + + /** Sets the webhook that test results post to (PUT /api/ipt/webhook). */ + public void setInboxPlacementWebhook( + @NotNull String webhookUrl, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (webhookUrl.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: webhook_url")); + return; + } + Map body = new LinkedHashMap<>(); + body.put("webhookUrl", webhookUrl); + inboxPlacementRequest("PUT", "/webhook", null, body, successCallback, errorCallback); + } + + private @Nullable String inboxPlacementTestPath( + @NotNull String testId, + @NotNull OnErrorCallback errorCallback + ) { + if (testId.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: test_id")); + return null; + } + return encodePathSegment(testId.trim()); + } + + private void inboxPlacementRequest( + @NotNull String method, + @NotNull String path, + @Nullable Map query, + @Nullable Map body, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + + Map parameters = query != null ? new LinkedHashMap<>(query) : new LinkedHashMap<>(); + parameters.put("api_key", apiKey); + + sendRequestWithMethod( + method, + inboxPlacementBaseUrl + path, + parameters, + null, + body, + new TypeToken() { + }, + successCallback, + errorCallback + ); + } + + /** The optional fields of a new Inbox Placement test. Only what is set is sent. */ + public static class CreateInboxPlacementTestOptions { + + @Nullable + private Integer testType = null; + @Nullable + private String senderEmail = null; + @Nullable + private List providerIds = null; + @Nullable + private List seedIds = null; + @Nullable + private List> providers = null; + @Nullable + private String webhookUrl = null; + + public CreateInboxPlacementTestOptions setTestType(@Nullable Integer testType) { + this.testType = testType; + return this; + } + + public CreateInboxPlacementTestOptions setSenderEmail(@Nullable String senderEmail) { + this.senderEmail = senderEmail; + return this; + } + + public CreateInboxPlacementTestOptions setProviderIds(@Nullable List providerIds) { + this.providerIds = providerIds; + return this; + } + + public CreateInboxPlacementTestOptions setSeedIds(@Nullable List seedIds) { + this.seedIds = seedIds; + return this; + } + + public CreateInboxPlacementTestOptions setProviders(@Nullable List> providers) { + this.providers = providers; + return this; + } + + public CreateInboxPlacementTestOptions setWebhookUrl(@Nullable String webhookUrl) { + this.webhookUrl = webhookUrl; + return this; + } + + void fill(@NotNull Map body) { + if (testType != null) body.put("testType", testType); + if (senderEmail != null) body.put("senderEmail", senderEmail); + if (providerIds != null) body.put("providerIds", providerIds); + if (seedIds != null) body.put("seedIds", seedIds); + if (providers != null) body.put("providers", providers); + if (webhookUrl != null) body.put("webhookUrl", webhookUrl); + } + } + + /** The documented filters of the Inbox Placement test list. */ + public static class ListInboxPlacementTestsOptions { + + @Nullable + private String status = null; + @Nullable + private Integer page = null; + @Nullable + private Integer pageSize = null; + + public ListInboxPlacementTestsOptions setStatus(@Nullable String status) { + this.status = status; + return this; + } + + public ListInboxPlacementTestsOptions setPage(@Nullable Integer page) { + this.page = page; + return this; + } + + public ListInboxPlacementTestsOptions setPageSize(@Nullable Integer pageSize) { + this.pageSize = pageSize; + return this; + } + + void fill(@NotNull Map query) { + if (status != null) query.put("status", status); + if (page != null) query.put("page", String.valueOf(page)); + if (pageSize != null) query.put("pageSize", String.valueOf(pageSize)); + } + } + /** * Checks if the [apiKey] is invalid or not and if it is, then it throws an error through the provided * [errorCallback]. @@ -1229,6 +1947,104 @@ private void sendRequest( } } + /** + * The helper that handles a request with an explicit method and an optional + * body, for the endpoints {@link #sendRequest} cannot express: form-encoded + * POSTs, and the Inbox Placement Tester's PUT and DELETE. + * + * @param method GET, POST, PUT or DELETE + * @param urlPath the url + * @param queryParameters the query parameters, appended to the url + * @param formFields form-encoded body fields, or null + * @param jsonBody a JSON body, or null. Mutually exclusive with formFields. + */ + private void sendRequestWithMethod( + @NotNull String method, + @NotNull String urlPath, + @Nullable Map queryParameters, + @Nullable Map formFields, + @Nullable Object jsonBody, + @Nullable TypeToken typeToken, + @Nullable OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback) { + try { + URIBuilder ub = new URIBuilder(urlPath); + if (queryParameters != null) { + for (Map.Entry param : queryParameters.entrySet()) { + ub.addParameter(param.getKey(), param.getValue()); + } + } + + URL url = createUrlFrom(ub.toString()); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + con.setRequestProperty("Accept", "application/json"); + con.setConnectTimeout(timeoutInMillis); + con.setReadTimeout(timeoutInMillis); + con.setRequestMethod(method); + + byte[] out = null; + if (formFields != null) { + StringBuilder form = new StringBuilder(); + for (Map.Entry field : formFields.entrySet()) { + if (form.length() > 0) form.append('&'); + form.append(urlEncode(field.getKey())).append('=').append(urlEncode(field.getValue())); + } + out = form.toString().getBytes(StandardCharsets.UTF_8); + con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + } else if (jsonBody != null) { + out = gson.toJson(jsonBody).getBytes(StandardCharsets.UTF_8); + con.setRequestProperty("Content-Type", "application/json"); + } + + if (out != null) { + con.setDoOutput(true); + con.setFixedLengthStreamingMode(out.length); + con.connect(); + try (OutputStream os = con.getOutputStream()) { + os.write(out); + } + } + + logDebug("ZeroBounceSDK::sendRequestWithMethod executing " + method + " " + urlPath); + + int status = con.getResponseCode(); + Reader streamReader = status > 299 + ? new InputStreamReader(con.getErrorStream()) + : new InputStreamReader(con.getInputStream()); + + StringBuilder content = new StringBuilder(); + try (BufferedReader in = new BufferedReader(streamReader)) { + String inputLine; + while ((inputLine = in.readLine()) != null) { + content.append(inputLine); + } + } + con.disconnect(); + String rsp = content.toString(); + logPayload("ZeroBounceSDK::sendRequestWithMethod rsp=" + rsp); + + if (status > 299) { + errorCallback.onError(ErrorResponse.parseError(rsp)); + return; + } + if (successCallback == null) { + return; + } + if (typeToken == null || rsp.trim().isEmpty()) { + successCallback.onSuccess(null); + return; + } + successCallback.onSuccess(gson.fromJson(rsp, typeToken.getType())); + } catch (Exception e) { + logError("ZeroBounceSDK::sendRequestWithMethod failed", e); + errorCallback.onError(ErrorResponse.parseError(e.getMessage())); + } + } + + private static String urlEncode(String value) { + return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8); + } + /** * Creates and returns a URL object based on the given [url] parameter. * 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..a796e80 --- /dev/null +++ b/zero-bounce-sdk/src/test/java/com/zerobounce/OfflineApiTest.java @@ -0,0 +1,565 @@ +package com.zerobounce; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Offline tests for the endpoints ported from the Python SDK: the validate + * options, real-time AI scoring, the filters, the list evaluator, the S3 and + * SFTP/FTP bulk jobs and the Inbox Placement Tester. + *

+ * Every request is answered by a mocked {@link HttpURLConnection}; nothing here + * reaches the live API. The assertions are on what actually went on the wire. + */ +@ExtendWith(MockitoExtension.class) +public class OfflineApiTest { + + @Spy + private ZeroBounceSDK zeroBounceSDK; + + private static final String API_KEY = "offline-key"; + + /** What one mocked call recorded. */ + private static class Recorded { + String url; + String method; + String body; + + /** The form body parsed into field/value pairs. */ + Map form() { + Map fields = new LinkedHashMap<>(); + if (body == null || body.isEmpty()) return fields; + for (String pair : body.split("&")) { + int split = pair.indexOf('='); + if (split < 0) continue; + fields.put( + URLDecoder.decode(pair.substring(0, split), StandardCharsets.UTF_8), + URLDecoder.decode(pair.substring(split + 1), StandardCharsets.UTF_8) + ); + } + return fields; + } + + /** The query string parsed into parameter/value pairs. */ + Map query() { + Map params = new LinkedHashMap<>(); + int mark = url.indexOf('?'); + if (mark < 0) return params; + for (String pair : url.substring(mark + 1).split("&")) { + int split = pair.indexOf('='); + if (split < 0) continue; + params.put( + URLDecoder.decode(pair.substring(0, split), StandardCharsets.UTF_8), + URLDecoder.decode(pair.substring(split + 1), StandardCharsets.UTF_8) + ); + } + return params; + } + + /** The url without its query string, still percent-encoded. */ + String path() { + int mark = url.indexOf('?'); + return mark < 0 ? url : url.substring(0, mark); + } + } + + private final List recorded = new ArrayList<>(); + + @BeforeEach + public void setUp() { + zeroBounceSDK.initialize(API_KEY); + recorded.clear(); + } + + /** + * Answers every request with the given JSON and records what was sent. + * Returns the recorded calls, in order. + */ + private List mockAll(int status, @NotNull String responseJson) throws IOException { + ByteArrayOutputStream sentBody = new ByteArrayOutputStream(); + + URL url = mock(URL.class, withSettings().useConstructor("https://api.zerobounce.net/v2/validate")); + HttpURLConnection con = mock(HttpURLConnection.class); + lenient().when(url.openConnection()).thenReturn(con); + lenient().when(con.getResponseCode()).thenReturn(status); + lenient().when(con.getOutputStream()).thenReturn(sentBody); + lenient().when(con.getInputStream()) + .thenAnswer(i -> new ByteArrayInputStream(responseJson.getBytes(StandardCharsets.UTF_8))); + lenient().when(con.getErrorStream()) + .thenAnswer(i -> new ByteArrayInputStream(responseJson.getBytes(StandardCharsets.UTF_8))); + + lenient().doAnswer(invocation -> { + Recorded record = new Recorded(); + record.url = invocation.getArgument(0); + recorded.add(record); + sentBody.reset(); + return url; + }).when(zeroBounceSDK).createUrlFrom(anyString()); + + lenient().doAnswer(invocation -> { + if (!recorded.isEmpty()) { + recorded.get(recorded.size() - 1).method = invocation.getArgument(0); + } + return null; + }).when(con).setRequestMethod(anyString()); + + // The body is written after setRequestMethod, so read it back lazily. + lenient().doAnswer(invocation -> { + if (!recorded.isEmpty()) { + recorded.get(recorded.size() - 1).body = sentBody.toString(StandardCharsets.UTF_8); + } + return null; + }).when(con).disconnect(); + + return recorded; + } + + private Recorded only() { + assertEquals(1, recorded.size(), "expected exactly one request"); + Recorded record = recorded.get(0); + recorded.clear(); + return record; + } + + private void failOnError(ErrorResponse error) { + fail(error.getErrors().toString()); + } + + // ------------------------------------------------------------------ + // Validate options + // ------------------------------------------------------------------ + + @Test + public void validateSendsTheNewOptionsOnlyWhenSet() throws IOException { + mockAll(200, """ + {"address":"a@example.com","status":"valid","active_in_days":"3", + "domain_website_exists":"true","domain_registrant_company_name":"Example Inc"}"""); + + zeroBounceSDK.validate("a@example.com", null, null, r -> { + }, this::failOnError); + Recorded record = only(); + assertEquals("GET", record.method); + for (String absent : new String[]{"timeout", "activity_data", "verify_plus", "domain_info", + "greylist_processing", "callback_url"}) { + assertFalse(record.query().containsKey(absent), absent + " should not be sent"); + } + + ZBValidateOptions options = new ZBValidateOptions() + .setTimeout(30) + .setActivityData(true) + .setVerifyPlus(false) + .setDomainInfo(true) + .setGreylistProcessing(true) + .setCallbackUrl("https://example.com/greylist"); + zeroBounceSDK.validate("a@example.com", null, options, response -> { + // The activity_data and domain_info blocks land on the response. + assertEquals("3", response.getActiveInDays()); + assertEquals("true", response.getDomainWebsiteExists()); + assertEquals("Example Inc", response.getDomainRegistrantCompanyName()); + }, this::failOnError); + record = only(); + Map query = record.query(); + assertEquals("30", query.get("timeout")); + // Booleans go on the wire as the strings "true"/"false". + assertEquals("true", query.get("activity_data")); + assertEquals("false", query.get("verify_plus")); + assertEquals("true", query.get("domain_info")); + assertEquals("true", query.get("greylist_processing")); + assertEquals("https://example.com/greylist", query.get("callback_url")); + + // usePost sends the same fields as a form body instead of a query string. + zeroBounceSDK.validate("a@example.com", null, + new ZBValidateOptions().setUsePost(true).setDomainInfo(true), r -> { + }, this::failOnError); + record = only(); + assertEquals("POST", record.method); + assertEquals("a@example.com", record.form().get("email")); + assertEquals("true", record.form().get("domain_info")); + } + + @Test + public void greylistMessageLandsOnTheResponse() throws IOException { + // While a greylist retry is still running the API answers with "Message" + // instead of a result. + mockAll(200, """ + {"address":"a@example.com","Message":"Greylisted email, we will retry"}"""); + + zeroBounceSDK.validate("a@example.com", null, null, + response -> assertEquals("Greylisted email, we will retry", response.getMessage()), + this::failOnError); + only(); + } + + @Test + public void invalidValidateOptionsSendNoRequest() throws IOException { + mockAll(200, "{}"); + + List errors = new ArrayList<>(); + ZeroBounceSDK.OnErrorCallback collect = error -> errors.addAll(error.getErrors()); + + zeroBounceSDK.validate("a@example.com", null, + new ZBValidateOptions().setCallbackUrl("https://example.com/greylist"), + r -> fail("success"), collect); + zeroBounceSDK.validate("a@example.com", null, + new ZBValidateOptions().setGreylistProcessing(false).setCallbackUrl("https://example.com/greylist"), + r -> fail("success"), collect); + assertEquals(2, errors.size()); + assertTrue(errors.stream().allMatch(e -> e.contains("greylist_processing")), errors.toString()); + + errors.clear(); + zeroBounceSDK.validate("a@example.com", null, + new ZBValidateOptions().setGreylistProcessing(true).setCallbackUrl(" "), + r -> fail("success"), collect); + assertTrue(errors.toString().contains("callback_url"), errors.toString()); + + errors.clear(); + zeroBounceSDK.validate("a@example.com", null, new ZBValidateOptions().setTimeout(2), + r -> fail("success"), collect); + assertTrue(errors.toString().contains("timeout"), errors.toString()); + + assertTrue(recorded.isEmpty(), "no request should have been sent"); + } + + // ------------------------------------------------------------------ + // Real-time AI scoring and filters + // ------------------------------------------------------------------ + + @Test + public void scoringAndFiltersUseTheMainHost() throws IOException { + mockAll(200, """ + {"email":"a@example.com","score":"10","status":"valid"}"""); + + zeroBounceSDK.scoreEmail("a@example.com", + response -> assertEquals("10", response.getScore()), this::failOnError); + Recorded record = only(); + assertEquals("GET", record.method); + assertTrue(record.path().endsWith("/v2/scoring"), record.path()); + assertEquals("a@example.com", record.query().get("email")); + + zeroBounceSDK.scoreEmail("a@example.com", true, r -> { + }, this::failOnError); + record = only(); + assertEquals("POST", record.method); + assertEquals("a@example.com", record.form().get("email")); + + zeroBounceSDK.addFilter("exclude", "domain", "example.com", r -> { + }, this::failOnError); + record = only(); + assertEquals("POST", record.method); + assertTrue(record.path().endsWith("/v2/filters/add"), record.path()); + assertEquals("exclude", record.form().get("rule")); + + zeroBounceSDK.listFilters(false, r -> { + }, this::failOnError); + assertEquals("GET", only().method); + zeroBounceSDK.listFilters(true, r -> { + }, this::failOnError); + assertEquals("POST", only().method); + + zeroBounceSDK.deleteFilter("exclude", "domain", "example.com", r -> { + }, this::failOnError); + assertTrue(only().path().endsWith("/v2/filters/delete")); + } + + @Test + public void scoreEmailRejectsABlankAddressBeforeARequest() throws IOException { + mockAll(200, "{}"); + List errors = new ArrayList<>(); + zeroBounceSDK.scoreEmail(" ", r -> fail("success"), e -> errors.addAll(e.getErrors())); + assertTrue(errors.toString().contains("email"), errors.toString()); + assertTrue(recorded.isEmpty()); + } + + // ------------------------------------------------------------------ + // S3 and SFTP/FTP bulk + // ------------------------------------------------------------------ + + @Test + public void s3AndSftpFileJobsSendTheDocumentedFieldsOnly() throws IOException { + mockAll(200, """ + {"success":true,"message":"ok","file_name":"f.csv","file_id":"fid"}"""); + + zeroBounceSDK.emailFinderS3File( + new ZBBulkFileJobOptions().setFileName("lists/leads.csv").setHasHeaderRow(true), + r -> { + }, this::failOnError); + Recorded record = only(); + assertEquals("POST", record.method); + // The S3 and SFTP jobs live on the bulkapi root, WITHOUT /v2. + assertEquals("https://bulkapi.zerobounce.net/emailFinderS3file", record.path()); + Map form = record.form(); + assertEquals("lists/leads.csv", form.get("file_name")); + assertEquals("true", form.get("has_header_row")); + // Unset options stay off the request entirely. + assertFalse(form.containsKey("remove_duplicates")); + assertFalse(form.containsKey("return_url")); + + zeroBounceSDK.domainSearchS3File( + new ZBBulkFileJobOptions() + .setFileName("lists/leads.csv") + .setHasHeaderRow(false) + .setRemoveDuplicates(true) + .setReturnUrl("https://example.com/done"), + r -> { + }, this::failOnError); + record = only(); + assertEquals("https://bulkapi.zerobounce.net/domainSearchS3file", record.path()); + form = record.form(); + assertEquals("false", form.get("has_header_row")); + assertEquals("true", form.get("remove_duplicates")); + assertEquals("https://example.com/done", form.get("return_url")); + + // file_name is optional for validateSFTPfile alone: without it the input + // bucket from the account's settings is used. + zeroBounceSDK.validateSFTPFile(new ZBBulkFileJobOptions().setHasHeaderRow(true), r -> { + }, this::failOnError); + record = only(); + assertEquals("https://bulkapi.zerobounce.net/validateSFTPfile", record.path()); + assertFalse(record.form().containsKey("file_name")); + assertEquals("true", record.form().get("has_header_row")); + + ZBBulkFileJobOptions named = new ZBBulkFileJobOptions().setFileName("my_file.txt"); + zeroBounceSDK.scoreSFTPFile(named, r -> { + }, this::failOnError); + assertEquals("https://bulkapi.zerobounce.net/scoreSFTPfile", only().path()); + zeroBounceSDK.emailFinderSFTPFile(named, r -> { + }, this::failOnError); + assertEquals("https://bulkapi.zerobounce.net/emailFinderSFTPfile", only().path()); + zeroBounceSDK.domainSearchSFTPFile(named, r -> { + }, this::failOnError); + assertEquals("https://bulkapi.zerobounce.net/domainSearchSFTPfile", only().path()); + zeroBounceSDK.validateS3File(named, r -> { + }, this::failOnError); + assertEquals("https://bulkapi.zerobounce.net/validateS3file", only().path()); + zeroBounceSDK.scoreS3File(named, r -> { + }, this::failOnError); + assertEquals("https://bulkapi.zerobounce.net/scoreS3file", only().path()); + + // exportSFTPfile takes export_path, not the export_bucket exportS3file uses. + zeroBounceSDK.exportSFTPFile("fid", "/exports", r -> { + }, this::failOnError); + record = only(); + assertEquals("https://bulkapi.zerobounce.net/exportSFTPfile", record.path()); + assertEquals("fid", record.form().get("file_id")); + assertEquals("/exports", record.form().get("export_path")); + assertFalse(record.form().containsKey("export_bucket")); + + zeroBounceSDK.exportSFTPFile("fid", null, r -> { + }, this::failOnError); + assertFalse(only().form().containsKey("export_path")); + + zeroBounceSDK.exportS3File("fid", "bucket-out", r -> { + }, this::failOnError); + record = only(); + assertEquals("https://bulkapi.zerobounce.net/exportS3file", record.path()); + assertEquals("bucket-out", record.form().get("export_bucket")); + } + + @Test + public void listS3FilesAcceptsBothDocumentedShapes() throws IOException { + mockAll(200, """ + {"success":true,"files":[{"Name":"lists/leads.csv","Size":12,"LastModified":"2026-01-01"}]}"""); + + zeroBounceSDK.listS3Files(null, null, files -> { + assertEquals(1, files.size()); + assertEquals("lists/leads.csv", files.get(0).getName()); + assertEquals(12, files.get(0).getSize()); + }, this::failOnError); + Recorded record = only(); + assertEquals("GET", record.method); + assertEquals("https://bulkapi.zerobounce.net/listS3files", record.path()); + assertFalse(record.query().containsKey("bucket")); + assertFalse(record.query().containsKey("search")); + + zeroBounceSDK.listS3Files("inbox", "csv", files -> { + }, this::failOnError); + record = only(); + assertEquals("inbox", record.query().get("bucket")); + assertEquals("csv", record.query().get("search")); + + // A bare array is also returned by the API. + mockAll(200, """ + [{"Name":"bare.csv","Size":1,"LastModified":""}]"""); + recorded.clear(); + zeroBounceSDK.listS3Files(null, null, files -> { + assertEquals(1, files.size()); + assertEquals("bare.csv", files.get(0).getName()); + }, this::failOnError); + only(); + } + + @Test + public void bulkJobsRejectInvalidArgumentsBeforeARequest() throws IOException { + mockAll(200, "{}"); + List errors = new ArrayList<>(); + ZeroBounceSDK.OnErrorCallback collect = error -> errors.addAll(error.getErrors()); + + ZBBulkFileJobOptions blank = new ZBBulkFileJobOptions().setFileName(" "); + zeroBounceSDK.validateS3File(blank, r -> fail("success"), collect); + zeroBounceSDK.scoreS3File(blank, r -> fail("success"), collect); + zeroBounceSDK.emailFinderS3File(blank, r -> fail("success"), collect); + zeroBounceSDK.domainSearchS3File(blank, r -> fail("success"), collect); + zeroBounceSDK.scoreSFTPFile(blank, r -> fail("success"), collect); + zeroBounceSDK.emailFinderSFTPFile(blank, r -> fail("success"), collect); + zeroBounceSDK.domainSearchSFTPFile(blank, r -> fail("success"), collect); + // An unset name is allowed for validateSFTPfile, but a blank one is not. + zeroBounceSDK.validateSFTPFile(blank, r -> fail("success"), collect); + assertEquals(8, errors.size()); + assertTrue(errors.stream().allMatch(e -> e.contains("file_name")), errors.toString()); + + errors.clear(); + zeroBounceSDK.exportSFTPFile(" ", null, r -> fail("success"), collect); + assertTrue(errors.toString().contains("file_id"), errors.toString()); + errors.clear(); + zeroBounceSDK.exportSFTPFile("fid", " ", r -> fail("success"), collect); + assertTrue(errors.toString().contains("export_path"), errors.toString()); + errors.clear(); + zeroBounceSDK.exportS3File("fid", " ", r -> fail("success"), collect); + assertTrue(errors.toString().contains("export_bucket"), errors.toString()); + + assertTrue(recorded.isEmpty(), "no request should have been sent"); + } + + // ------------------------------------------------------------------ + // List evaluator and Inbox Placement Tester + // ------------------------------------------------------------------ + + @Test + public void listEvaluatorUsesItsOwnBaseUrlAndEncodesTheFileId() throws IOException { + mockAll(200, "{\"success\":true}"); + + // The file id is one path segment, so a slash in it must stay encoded. + zeroBounceSDK.getListEvaluatorResult("a b/c", r -> { + }, this::failOnError); + Recorded record = only(); + assertEquals("GET", record.method); + assertEquals("https://bulkapi.zerobounce.net/v2/listevaluator/a%20b%2Fc/", record.path()); + + List errors = new ArrayList<>(); + zeroBounceSDK.getListEvaluatorResult(" ", r -> fail("success"), e -> errors.addAll(e.getErrors())); + assertTrue(errors.toString().contains("file_id"), errors.toString()); + assertTrue(recorded.isEmpty()); + } + + @Test + public void inboxPlacementUsesItsOwnHostAndCamelCaseJson() throws IOException { + mockAll(200, "{\"ok\":true}"); + + zeroBounceSDK.getInboxPlacementProviders(r -> { + }, this::failOnError); + Recorded record = only(); + assertEquals("https://bulkapi.zerobounce.net/api/ipt/providers", record.path()); + // The API key goes on the query string here, not in the body. + assertEquals(API_KEY, record.query().get("api_key")); + + zeroBounceSDK.getInboxPlacementTestTypes(r -> { + }, this::failOnError); + assertEquals("https://bulkapi.zerobounce.net/api/ipt/test-types", only().path()); + + zeroBounceSDK.createInboxPlacementTest("Launch", + new ZeroBounceSDK.CreateInboxPlacementTestOptions() + .setTestType(1) + .setSenderEmail("news@example.com") + .setWebhookUrl("https://example.com/ipt"), + r -> { + }, this::failOnError); + record = only(); + assertEquals("POST", record.method); + assertEquals("https://bulkapi.zerobounce.net/api/ipt/test", record.path()); + // Bodies are camelCase JSON, and unset fields are left out. + assertTrue(record.body.contains("\"testType\":1"), record.body); + assertTrue(record.body.contains("\"senderEmail\":\"news@example.com\""), record.body); + assertTrue(record.body.contains("\"webhookUrl\":\"https://example.com/ipt\""), record.body); + assertFalse(record.body.contains("providerIds"), record.body); + assertFalse(record.body.contains("seedIds"), record.body); + + zeroBounceSDK.listInboxPlacementTests( + new ZeroBounceSDK.ListInboxPlacementTestsOptions() + .setStatus("finished").setPage(1).setPageSize(25), + r -> { + }, this::failOnError); + record = only(); + assertEquals("finished", record.query().get("status")); + assertEquals("1", record.query().get("page")); + assertEquals("25", record.query().get("pageSize")); + + zeroBounceSDK.listInboxPlacementTests(null, r -> { + }, this::failOnError); + record = only(); + assertFalse(record.query().containsKey("status")); + assertFalse(record.query().containsKey("page")); + + zeroBounceSDK.deleteInboxPlacementTest("a b/c", r -> { + }, this::failOnError); + record = only(); + assertEquals("DELETE", record.method); + assertEquals("https://bulkapi.zerobounce.net/api/ipt/test/a%20b%2Fc", record.path()); + + zeroBounceSDK.setInboxPlacementWebhook("https://example.com/ipt", r -> { + }, this::failOnError); + record = only(); + assertEquals("PUT", record.method); + assertEquals("https://bulkapi.zerobounce.net/api/ipt/webhook", record.path()); + assertTrue(record.body.contains("\"webhookUrl\":\"https://example.com/ipt\""), record.body); + + zeroBounceSDK.getInboxPlacementWebhook(r -> { + }, this::failOnError); + assertEquals("https://bulkapi.zerobounce.net/api/ipt/webhook", only().path()); + + List errors = new ArrayList<>(); + ZeroBounceSDK.OnErrorCallback collect = error -> errors.addAll(error.getErrors()); + zeroBounceSDK.getInboxPlacementTest(" ", r -> fail("success"), collect); + assertTrue(errors.toString().contains("test_id"), errors.toString()); + errors.clear(); + zeroBounceSDK.createInboxPlacementTest(" ", null, r -> fail("success"), collect); + assertTrue(errors.toString().contains("name"), errors.toString()); + errors.clear(); + zeroBounceSDK.setInboxPlacementWebhook("", r -> fail("success"), collect); + assertTrue(errors.toString().contains("webhook_url"), errors.toString()); + assertTrue(recorded.isEmpty()); + } + + @Test + public void detailErrorBodyBecomesTheMessage() throws IOException { + // The Inbox Placement Tester is a Django REST framework app: it reports + // errors under "detail" rather than the "error"/"message" keys the rest + // of the API uses. + mockAll(404, "{\"detail\":\"Inbox placement test not found.\"}"); + + List errors = new ArrayList<>(); + zeroBounceSDK.getInboxPlacementTest("123", r -> fail("success"), e -> errors.addAll(e.getErrors())); + assertTrue(errors.toString().contains("Inbox placement test not found."), errors.toString()); + } + + @Test + public void listEvaluatorAndInboxPlacementBaseUrlsAreOverridable() { + // These are separate hosts, not paths built from the bulk base, so an + // override of the main base URL alone would leave them on the live API. + zeroBounceSDK.initialize(API_KEY, "https://api.example.test/v2", + "https://bulk.example.test/v2/listevaluator", "https://bulk.example.test/api/ipt"); + assertEquals("https://bulk.example.test/v2/listevaluator", zeroBounceSDK.listEvaluatorBaseUrl); + assertEquals("https://bulk.example.test/api/ipt", zeroBounceSDK.inboxPlacementBaseUrl); + assertThrows(IllegalArgumentException.class, () -> + zeroBounceSDK.initialize(API_KEY, null, "http://evil.example", null)); + } +} From 2eb13ff922362805dbb7b220adf03fc680a3cd0c Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:53:29 +0400 Subject: [PATCH 2/7] feat: add ai_agent_mailbox sub_status (RD-66) The API now returns ai_agent_mailbox under both the valid and catch-all statuses. No new status was added. Adds AI_AGENT_MAILBOX to ZBValidateSubStatus with its @SerializedName, following ROLE_BASED_CATCH_ALL. Unknown values were already safe: Gson maps an unrecognised enum constant to null rather than throwing, so a sub_status added after this SDK shipped degrades to null instead of failing the validate call. That had no coverage, so a test now pins it alongside the two new ones. Co-Authored-By: Claude Opus 5 --- .../com/zerobounce/ZBValidateSubStatus.java | 5 +- .../com/zerobounce/ZeroBounceSDKTest.java | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateSubStatus.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateSubStatus.java index 8c85944..9b82074 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateSubStatus.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateSubStatus.java @@ -97,5 +97,8 @@ public enum ZBValidateSubStatus { ROLE_BASED_ACCEPT_ALL, @SerializedName("gold") - GOLD + GOLD, + + @SerializedName("ai_agent_mailbox") + AI_AGENT_MAILBOX } \ No newline at end of file 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..9d28c81 100644 --- a/zero-bounce-sdk/src/test/java/com/zerobounce/ZeroBounceSDKTest.java +++ b/zero-bounce-sdk/src/test/java/com/zerobounce/ZeroBounceSDKTest.java @@ -126,6 +126,63 @@ public void validateEmail_ReturnsSuccess() throws Exception { ); } + + @Test + public void validateEmail_AiAgentMailbox_Valid() throws Exception { + assertSubStatusParsed("valid", "ai_agent_mailbox", ZBValidateSubStatus.AI_AGENT_MAILBOX); + } + + @Test + public void validateEmail_AiAgentMailbox_CatchAll() throws Exception { + assertSubStatusParsed("catch-all", "ai_agent_mailbox", ZBValidateSubStatus.AI_AGENT_MAILBOX); + } + + @Test + public void validateEmail_UnknownSubStatus_IsNullNotAnError() throws Exception { + // Gson maps an unrecognised enum constant to null rather than throwing, + // so a sub_status added to the API after this SDK shipped degrades to + // null instead of failing the whole validate call. + assertSubStatusParsed("valid", "some_future_sub_status", null); + } + + /** Runs a validate call whose response carries the given status/sub_status and checks the parsed enum. */ + private void assertSubStatusParsed( + String status, + String subStatus, + ZBValidateSubStatus expected + ) throws Exception { + String email = "agent@example.com"; + String responseJson = """ + { + "address":"%s", + "status":"%s", + "sub_status":"%s" + }""".formatted(email, status, subStatus); + + String urlPath = getEncodedUrl( + "https://api.zerobounce.net/v2/validate", + new HashMap<>() { + { + put("api_key", API_KEY); + put("email", email); + put("ip_address", ""); + } + } + ); + mockRequest( + urlPath, + 200, + responseJson, + "", + () -> zeroBounceSDK.validate( + email, + null, + response -> assertEquals(expected, response.getSubStatus()), + errorResponse -> fail(errorResponse.toString()) + ) + ); + } + @Test public void validateEmail_ReturnsError() throws Exception { // Prepare mock response and add it to the server From 673806b20f67ed2c886cc2d362de25e5d1666393 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:04:09 +0400 Subject: [PATCH 3/7] fix: match what the live API actually accepts Verified against the live API; where the API and the docs disagree, the API wins and the call site says so. - validateBatch gains a ZBValidateOptions overload, and sends timeout as a JSON string. Sent as a number the API rejects the whole batch with the account-level "Invalid API Key or your account ran out of credits". Until now the batch had no options at all. - The Inbox Placement Tester authenticates with an Authorization: Bearer header, not an api_key parameter, so the key no longer reaches a query string that gets logged. Its listing is GET /tests; /test is the single-test path and answers Method "GET" not allowed. - sendEvaluatedFile posts the list evaluator's file_id as a form field. The endpoint was missing from this SDK. - The S3 and SFTP jobs require file_name and return_url, validateSFTPFile included, although the docs list file_name as optional for that one. {"success": false} on an HTTP 200 is a failure as well: the API answers an unknown file id that way. ErrorResponse now carries the HTTP status through getStatusCode(), on every path including the multipart uploads and the bulk download. Co-Authored-By: Claude Opus 5 --- .../java/com/zerobounce/ErrorResponse.java | 27 ++- .../com/zerobounce/ZBBulkFileJobOptions.java | 25 +-- .../com/zerobounce/ZBValidateOptions.java | 24 +++ .../java/com/zerobounce/ZeroBounceSDK.java | 196 +++++++++++++++--- .../java/com/zerobounce/OfflineApiTest.java | 125 ++++++++++- .../com/zerobounce/ZeroBounceSDKTest.java | 32 +-- 6 files changed, 362 insertions(+), 67 deletions(-) diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ErrorResponse.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ErrorResponse.java index 4d49526..357650d 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ErrorResponse.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ErrorResponse.java @@ -30,6 +30,21 @@ public class ErrorResponse { @NotNull private ArrayList errors = new ArrayList<>(); + /** + * The HTTP status of the failed response, or 0 when the failure never + * reached the API. It lets callers tell a 403 from Cloudflare apart from a + * 400/404 from the API and a 429 from the Warmup rate limit. + */ + private int statusCode = 0; + + public int getStatusCode() { + return statusCode; + } + + public void setStatusCode(int statusCode) { + this.statusCode = statusCode; + } + public @Nullable Boolean getSuccess() { return success; } @@ -51,12 +66,14 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ErrorResponse that = (ErrorResponse) o; - return Objects.equals(success, that.success) && Objects.equals(errors, that.errors); + return Objects.equals(success, that.success) + && Objects.equals(errors, that.errors) + && statusCode == that.statusCode; } @Override public int hashCode() { - return Objects.hash(success, errors); + return Objects.hash(success, errors, statusCode); } /** @@ -68,6 +85,12 @@ public int hashCode() { * @param error the error that will be parsed * @return an [ErrorResponse] object */ + protected static ErrorResponse parseError(@Nullable String error, int statusCode) { + ErrorResponse response = parseError(error); + response.setStatusCode(statusCode); + return response; + } + protected static ErrorResponse parseError(@Nullable String error) { ErrorResponse response = new ErrorResponse(); if (error == null) return response; diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBBulkFileJobOptions.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBBulkFileJobOptions.java index 5c0cf72..f0f04ab 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBBulkFileJobOptions.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBBulkFileJobOptions.java @@ -8,10 +8,10 @@ /** * The documented fields shared by the six S3 and SFTP/FTP file jobs. *

- * {@code fileName} is required by every job except - * {@link ZeroBounceSDK#validateSFTPFile}, where the docs list it as optional: - * leaving it null there uses the input bucket configured in your ZeroBounce - * settings. + * LIVE: {@code fileName} and {@code returnUrl} are required on every one of + * them, {@code validateSFTPFile} included, although the docs list + * {@code fileName} as optional for that one. Without either the API answers + * "This field is required". */ public class ZBBulkFileJobOptions { @@ -65,23 +65,20 @@ public boolean isHasHeaderRow() { /** * Adds the documented fields to the given form map. * - * @param fileNameRequired false only for validateSFTPfile * @throws IllegalArgumentException when a required field is missing or blank */ - void fill(@NotNull Map fields, boolean fileNameRequired) { - boolean blank = fileName != null && fileName.trim().isEmpty(); - if ((fileName == null && fileNameRequired) || blank) { + void fill(@NotNull Map fields) { + if (fileName == null || fileName.trim().isEmpty()) { throw new IllegalArgumentException("Empty parameter: file_name"); } - fields.put("has_header_row", String.valueOf(hasHeaderRow)); - if (fileName != null) { - fields.put("file_name", fileName); + if (returnUrl == null || returnUrl.trim().isEmpty()) { + throw new IllegalArgumentException("Empty parameter: return_url"); } + fields.put("has_header_row", String.valueOf(hasHeaderRow)); + fields.put("file_name", fileName); + fields.put("return_url", returnUrl); if (removeDuplicates != null) { fields.put("remove_duplicates", String.valueOf(removeDuplicates)); } - if (returnUrl != null) { - fields.put("return_url", returnUrl); - } } } diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateOptions.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateOptions.java index ab1f020..1c12c86 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateOptions.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBValidateOptions.java @@ -107,6 +107,30 @@ public boolean isUsePost() { return usePost; } + /** + * Adds the options that are set to a validatebatch JSON body. + *

+ * LIVE: {@code timeout} goes out as a JSON string. Sent as a + * number the API rejects the whole batch with the account-level + * "Invalid API Key or your account ran out of credits". + * + * @throws IllegalArgumentException if the options cannot produce a valid request + */ + void fillBatch(@NotNull Map body) { + if (timeout != null) { + if (timeout < 10 || timeout > 120) { + throw new IllegalArgumentException("timeout must be between 10 and 120 seconds"); + } + body.put("timeout", String.valueOf(timeout)); + } + if (activityData != null) { + body.put("activity_data", activityData); + } + if (verifyPlus != null) { + body.put("verify_plus", verifyPlus); + } + } + /** * Adds the options that are set to the given parameter map. * 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 58dacd7..5a750b5 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java @@ -6,6 +6,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import com.google.gson.JsonParser; import com.google.gson.reflect.TypeToken; import org.apache.http.Header; @@ -226,12 +227,42 @@ public void validateBatch( @NotNull List emails, @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback + ) { + validateBatch(emails, null, successCallback, errorCallback); + } + + /** + * Validates a batch of emails, with the documented optional parameters. + *

+ * An option left null is not sent. The batch {@code timeout} is 10 to 120 + * seconds, and goes out as a JSON string: see + * {@link ZBValidateOptions#fillBatch(Map)}. + * + * @param emails the email addresses you want to validate + * @param options the optional parameters, or null for none + * @param successCallback the success callback + * @param errorCallback the error callback + */ + public void validateBatch( + @NotNull List emails, + @Nullable ZBValidateOptions options, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback ) { if (invalidApiKey(errorCallback)) return; - Map body = new HashMap<>(); + Map body = new LinkedHashMap<>(); body.put("api_key", apiKey); body.put("email_batch", emails); + try { + if (options != null) { + options.fillBatch(body); + } + } catch (IllegalArgumentException e) { + // Rejected before any request is sent. + errorCallback.onError(ErrorResponse.parseError(e.getMessage())); + return; + } sendRequest( apiBaseUrl + "/validatebatch", @@ -669,8 +700,11 @@ private void _sendFile( logDebug("ZeroBounceSDK::sendFile rsp=" + rsp); if (status > 299) { - ErrorResponse errorResponse = ErrorResponse.parseError(rsp); - errorCallback.onError(errorResponse); + errorCallback.onError(ErrorResponse.parseError(rsp, status)); + } else if (apiSuccessIsFalse(rsp)) { + // LIVE: {"success": false} on an HTTP 200 is a failure, not a + // result. The API answers an unknown bulk file id that way. + errorCallback.onError(ErrorResponse.parseError(rsp, status)); } else { Type type = new TypeToken() { }.getType(); @@ -783,8 +817,11 @@ private void _sendFileStream( logDebug("ZeroBounceSDK::sendFile rsp=" + rsp); if (status > 299) { - ErrorResponse errorResponse = ErrorResponse.parseError(rsp); - errorCallback.onError(errorResponse); + errorCallback.onError(ErrorResponse.parseError(rsp, status)); + } else if (apiSuccessIsFalse(rsp)) { + // LIVE: {"success": false} on an HTTP 200 is a failure, not a + // result. The API answers an unknown bulk file id that way. + errorCallback.onError(ErrorResponse.parseError(rsp, status)); } else { Type type = new TypeToken() { }.getType(); @@ -1020,21 +1057,22 @@ private void _getFile( try { JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); if (getFileJsonIndicatesFailure(obj)) { - errorCallback.onError(ErrorResponse.parseError(json)); + errorCallback.onError(ErrorResponse.parseError(json, statusCode)); return; } if (obj.has("success")) { - errorCallback.onError(ErrorResponse.parseError(json)); + errorCallback.onError(ErrorResponse.parseError(json, statusCode)); return; } } catch (Exception parseEx) { - errorCallback.onError(ErrorResponse.parseError(json)); + errorCallback.onError(ErrorResponse.parseError(json, statusCode)); return; } } if (statusCode > 299) { - errorCallback.onError(ErrorResponse.parseError(new String(bodyBytes, StandardCharsets.UTF_8))); + errorCallback.onError(ErrorResponse.parseError( + new String(bodyBytes, StandardCharsets.UTF_8), statusCode)); return; } @@ -1371,7 +1409,7 @@ public void validateS3File( @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback ) { - bulkRootFileJob("/validateS3file", options, true, successCallback, errorCallback); + bulkRootFileJob("/validateS3file", options, successCallback, errorCallback); } /** Scores a file in your Amazon S3 bucket (POST /scoreS3file). */ @@ -1380,7 +1418,7 @@ public void scoreS3File( @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback ) { - bulkRootFileJob("/scoreS3file", options, true, successCallback, errorCallback); + bulkRootFileJob("/scoreS3file", options, successCallback, errorCallback); } /** Runs the email finder on a file in your Amazon S3 bucket (POST /emailFinderS3file). */ @@ -1389,7 +1427,7 @@ public void emailFinderS3File( @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback ) { - bulkRootFileJob("/emailFinderS3file", options, true, successCallback, errorCallback); + bulkRootFileJob("/emailFinderS3file", options, successCallback, errorCallback); } /** Runs domain search on a file in your Amazon S3 bucket (POST /domainSearchS3file). */ @@ -1398,7 +1436,7 @@ public void domainSearchS3File( @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback ) { - bulkRootFileJob("/domainSearchS3file", options, true, successCallback, errorCallback); + bulkRootFileJob("/domainSearchS3file", options, successCallback, errorCallback); } /** @@ -1431,7 +1469,7 @@ public void validateSFTPFile( @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback ) { - bulkRootFileJob("/validateSFTPfile", options, false, successCallback, errorCallback); + bulkRootFileJob("/validateSFTPfile", options, successCallback, errorCallback); } /** Scores a file from your SFTP/FTP server (POST /scoreSFTPfile). */ @@ -1440,7 +1478,7 @@ public void scoreSFTPFile( @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback ) { - bulkRootFileJob("/scoreSFTPfile", options, true, successCallback, errorCallback); + bulkRootFileJob("/scoreSFTPfile", options, successCallback, errorCallback); } /** Runs the email finder on a file from your SFTP/FTP server (POST /emailFinderSFTPfile). */ @@ -1449,7 +1487,7 @@ public void emailFinderSFTPFile( @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback ) { - bulkRootFileJob("/emailFinderSFTPfile", options, true, successCallback, errorCallback); + bulkRootFileJob("/emailFinderSFTPfile", options, successCallback, errorCallback); } /** Runs domain search on a file from your SFTP/FTP server (POST /domainSearchSFTPfile). */ @@ -1458,7 +1496,7 @@ public void domainSearchSFTPFile( @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback ) { - bulkRootFileJob("/domainSearchSFTPfile", options, true, successCallback, errorCallback); + bulkRootFileJob("/domainSearchSFTPfile", options, successCallback, errorCallback); } /** @@ -1484,7 +1522,6 @@ public void exportSFTPFile( private void bulkRootFileJob( @NotNull String path, @NotNull ZBBulkFileJobOptions options, - boolean fileNameRequired, @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback ) { @@ -1492,7 +1529,7 @@ private void bulkRootFileJob( Map fields = new LinkedHashMap<>(); try { - options.fill(fields, fileNameRequired); + options.fill(fields); } catch (IllegalArgumentException e) { errorCallback.onError(ErrorResponse.parseError(e.getMessage())); return; @@ -1586,6 +1623,47 @@ public void getListEvaluatorResult( ); } + /** + * Submits a finished evaluation for validation (POST /v2/send-evaluated-file/). + *

+ * LIVE: this takes the list evaluator's {@code file_id} as a form field, + * not the file upload the docs describe - an upload is rejected with + * "file_id: This field is required". It returns a new validation job's + * {@code file_id}, which answers "File cannot be found" for a short while + * after it is accepted. + * + * @param fileId the file_id returned by the list evaluator + * @param successCallback the success callback + * @param errorCallback the error callback + */ + public void sendEvaluatedFile( + @NotNull String fileId, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback + ) { + if (invalidApiKey(errorCallback)) return; + if (fileId.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError("Empty parameter: file_id")); + return; + } + + Map fields = new LinkedHashMap<>(); + fields.put("api_key", apiKey); + fields.put("file_id", fileId.trim()); + + sendRequestWithMethod( + "POST", + bulkApiBaseUrl + "/send-evaluated-file/", + null, + fields, + null, + new TypeToken() { + }, + successCallback, + errorCallback + ); + } + /** URL-encodes a value as a single path segment, so a slash in it stays encoded. */ private static String encodePathSegment(String value) { return urlEncode(value).replace("+", "%20"); @@ -1649,7 +1727,12 @@ public void getInboxPlacementTest( inboxPlacementRequest("GET", "/test/" + segment, null, null, successCallback, errorCallback); } - /** The account's tests (GET /api/ipt/test). */ + /** + * The account's tests (GET /api/ipt/tests). + *

+ * LIVE: the listing is {@code /tests} (plural). {@code /test} is the + * single-test path and answers {@code Method "GET" not allowed}. + */ public void listInboxPlacementTests( @Nullable ListInboxPlacementTestsOptions options, @NotNull OnSuccessCallback successCallback, @@ -1659,7 +1742,7 @@ public void listInboxPlacementTests( if (options != null) { options.fill(query); } - inboxPlacementRequest("GET", "/test", query, null, successCallback, errorCallback); + inboxPlacementRequest("GET", "/tests", query, null, successCallback, errorCallback); } /** Deletes one test (DELETE /api/ipt/test/{id}). */ @@ -1717,8 +1800,10 @@ private void inboxPlacementRequest( ) { if (invalidApiKey(errorCallback)) return; + // LIVE: the Inbox Placement Tester authenticates with a Bearer header. + // Sent as an api_key parameter the request is rejected as unauthorized, + // and the key would end up in a query string that gets logged. Map parameters = query != null ? new LinkedHashMap<>(query) : new LinkedHashMap<>(); - parameters.put("api_key", apiKey); sendRequestWithMethod( method, @@ -1728,6 +1813,7 @@ private void inboxPlacementRequest( body, new TypeToken() { }, + Map.of("Authorization", "Bearer " + apiKey), successCallback, errorCallback ); @@ -1934,8 +2020,11 @@ private void sendRequest( logPayload("ZeroBounceSDK::sendRequest rsp=" + rsp); if (status > 299) { - ErrorResponse errorResponse = ErrorResponse.parseError(rsp); - errorCallback.onError(errorResponse); + errorCallback.onError(ErrorResponse.parseError(rsp, status)); + } else if (apiSuccessIsFalse(rsp)) { + // LIVE: {"success": false} on an HTTP 200 is a failure, not a + // result. The API answers an unknown bulk file id that way. + errorCallback.onError(ErrorResponse.parseError(rsp, status)); } else { T response = gson.fromJson(rsp, typeToken.getType()); successCallback.onSuccess(response); @@ -1967,6 +2056,20 @@ private void sendRequestWithMethod( @Nullable TypeToken typeToken, @Nullable OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback) { + sendRequestWithMethod(method, urlPath, queryParameters, formFields, jsonBody, typeToken, + null, successCallback, errorCallback); + } + + private void sendRequestWithMethod( + @NotNull String method, + @NotNull String urlPath, + @Nullable Map queryParameters, + @Nullable Map formFields, + @Nullable Object jsonBody, + @Nullable TypeToken typeToken, + @Nullable Map extraHeaders, + @Nullable OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback) { try { URIBuilder ub = new URIBuilder(urlPath); if (queryParameters != null) { @@ -1978,6 +2081,11 @@ private void sendRequestWithMethod( URL url = createUrlFrom(ub.toString()); HttpURLConnection con = (HttpURLConnection) url.openConnection(); con.setRequestProperty("Accept", "application/json"); + if (extraHeaders != null) { + for (Map.Entry header : extraHeaders.entrySet()) { + con.setRequestProperty(header.getKey(), header.getValue()); + } + } con.setConnectTimeout(timeoutInMillis); con.setReadTimeout(timeoutInMillis); con.setRequestMethod(method); @@ -2024,7 +2132,13 @@ private void sendRequestWithMethod( logPayload("ZeroBounceSDK::sendRequestWithMethod rsp=" + rsp); if (status > 299) { - errorCallback.onError(ErrorResponse.parseError(rsp)); + errorCallback.onError(ErrorResponse.parseError(rsp, status)); + return; + } + // LIVE: {"success": false} on an HTTP 200 is a failure, not a + // result. The API answers an unknown bulk file id that way. + if (apiSuccessIsFalse(rsp)) { + errorCallback.onError(ErrorResponse.parseError(rsp, status)); return; } if (successCallback == null) { @@ -2041,6 +2155,38 @@ private void sendRequestWithMethod( } } + /** + * Whether a JSON body says the call failed although the HTTP status was a + * success. The API sends {@code success} as a JSON false, "false" or + * "False" depending on the endpoint. + */ + private static boolean apiSuccessIsFalse(@Nullable String body) { + if (body == null) { + return false; + } + String trimmed = body.trim(); + if (!trimmed.startsWith("{")) { + return false; + } + try { + JsonObject parsed = JsonParser.parseString(trimmed).getAsJsonObject(); + if (parsed == null || !parsed.has("success")) { + return false; + } + JsonElement value = parsed.get("success"); + if (!value.isJsonPrimitive()) { + return false; + } + JsonPrimitive primitive = value.getAsJsonPrimitive(); + if (primitive.isBoolean()) { + return !primitive.getAsBoolean(); + } + return primitive.isString() && "false".equalsIgnoreCase(primitive.getAsString().trim()); + } catch (RuntimeException e) { + return false; + } + } + private static String urlEncode(String value) { return java.net.URLEncoder.encode(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 index a796e80..90adbff 100644 --- a/zero-bounce-sdk/src/test/java/com/zerobounce/OfflineApiTest.java +++ b/zero-bounce-sdk/src/test/java/com/zerobounce/OfflineApiTest.java @@ -44,6 +44,8 @@ private static class Recorded { String url; String method; String body; + /** Request headers, for the Inbox Placement Tester's Bearer key. */ + final Map headers = new LinkedHashMap<>(); /** The form body parsed into field/value pairs. */ Map form() { @@ -123,6 +125,14 @@ private List mockAll(int status, @NotNull String responseJson) throws return null; }).when(con).setRequestMethod(anyString()); + lenient().doAnswer(invocation -> { + if (!recorded.isEmpty()) { + recorded.get(recorded.size() - 1).headers + .put(invocation.getArgument(0), invocation.getArgument(1)); + } + return null; + }).when(con).setRequestProperty(anyString(), anyString()); + // The body is written after setRequestMethod, so read it back lazily. lenient().doAnswer(invocation -> { if (!recorded.isEmpty()) { @@ -300,7 +310,10 @@ public void s3AndSftpFileJobsSendTheDocumentedFieldsOnly() throws IOException { {"success":true,"message":"ok","file_name":"f.csv","file_id":"fid"}"""); zeroBounceSDK.emailFinderS3File( - new ZBBulkFileJobOptions().setFileName("lists/leads.csv").setHasHeaderRow(true), + new ZBBulkFileJobOptions() + .setFileName("lists/leads.csv") + .setHasHeaderRow(true) + .setReturnUrl("https://example.com/done"), r -> { }, this::failOnError); Recorded record = only(); @@ -310,9 +323,9 @@ public void s3AndSftpFileJobsSendTheDocumentedFieldsOnly() throws IOException { Map form = record.form(); assertEquals("lists/leads.csv", form.get("file_name")); assertEquals("true", form.get("has_header_row")); + assertEquals("https://example.com/done", form.get("return_url")); // Unset options stay off the request entirely. assertFalse(form.containsKey("remove_duplicates")); - assertFalse(form.containsKey("return_url")); zeroBounceSDK.domainSearchS3File( new ZBBulkFileJobOptions() @@ -329,16 +342,17 @@ record = only(); assertEquals("true", form.get("remove_duplicates")); assertEquals("https://example.com/done", form.get("return_url")); - // file_name is optional for validateSFTPfile alone: without it the input - // bucket from the account's settings is used. - zeroBounceSDK.validateSFTPFile(new ZBBulkFileJobOptions().setHasHeaderRow(true), r -> { + // LIVE: file_name is required here too, although the docs list it as + // optional for this one endpoint. + ZBBulkFileJobOptions named = new ZBBulkFileJobOptions() + .setFileName("my_file.txt") + .setReturnUrl("https://example.com/done"); + zeroBounceSDK.validateSFTPFile(named, r -> { }, this::failOnError); record = only(); assertEquals("https://bulkapi.zerobounce.net/validateSFTPfile", record.path()); - assertFalse(record.form().containsKey("file_name")); + assertEquals("my_file.txt", record.form().get("file_name")); assertEquals("true", record.form().get("has_header_row")); - - ZBBulkFileJobOptions named = new ZBBulkFileJobOptions().setFileName("my_file.txt"); zeroBounceSDK.scoreSFTPFile(named, r -> { }, this::failOnError); assertEquals("https://bulkapi.zerobounce.net/scoreSFTPfile", only().path()); @@ -461,6 +475,95 @@ public void listEvaluatorUsesItsOwnBaseUrlAndEncodesTheFileId() throws IOExcepti assertTrue(recorded.isEmpty()); } + @Test + public void successFalseOnAnHttp200IsAnError() throws IOException { + // LIVE: the API answers an unknown file id with an HTTP 200 whose body + // says success: false. + mockAll(200, """ + {"success":false,"message":"File cannot be found."}"""); + + List errors = new ArrayList<>(); + int[] status = {-1}; + zeroBounceSDK.fileStatus("0d1b7a4e-1111-2222-3333-444455556666", + r -> fail("success"), + error -> { + errors.addAll(error.getErrors()); + status[0] = error.getStatusCode(); + }); + assertTrue(errors.toString().contains("File cannot be found."), errors.toString()); + assertEquals(200, status[0]); + } + + @Test + public void validateBatchSendsTimeoutAsAString() throws IOException { + // LIVE: sent as a JSON number the API rejects the whole batch with the + // account-level "Invalid API Key or your account ran out of credits". + mockAll(200, """ + {"email_batch":[],"errors":[]}"""); + + zeroBounceSDK.validateBatch( + List.of(new ZBValidateBatchData("a@example.com", null)), + new ZBValidateOptions().setTimeout(30).setVerifyPlus(true), + r -> { + }, this::failOnError); + Recorded record = only(); + assertTrue(record.body.contains("\"timeout\":\"30\""), record.body); + assertTrue(record.body.contains("\"verify_plus\":true"), record.body); + + List errors = new ArrayList<>(); + zeroBounceSDK.validateBatch( + List.of(new ZBValidateBatchData("a@example.com", null)), + new ZBValidateOptions().setTimeout(5), + r -> fail("success"), error -> errors.addAll(error.getErrors())); + assertTrue(errors.toString().contains("timeout"), errors.toString()); + assertTrue(recorded.isEmpty()); + } + + @Test + public void sendEvaluatedFilePostsTheFileIdAsAFormField() throws IOException { + // LIVE: send-evaluated-file takes the evaluator's file_id as a form + // field, not the file upload the docs describe. + mockAll(200, """ + {"success":true,"message":"ok","file_id":"new-fid"}"""); + + zeroBounceSDK.sendEvaluatedFile("eval-file-1", r -> { + }, this::failOnError); + Recorded record = only(); + assertEquals("POST", record.method); + assertEquals("https://bulkapi.zerobounce.net/v2/send-evaluated-file/", record.path()); + assertEquals("eval-file-1", record.form().get("file_id")); + + List errors = new ArrayList<>(); + zeroBounceSDK.sendEvaluatedFile(" ", r -> fail("success"), + error -> errors.addAll(error.getErrors())); + assertTrue(errors.toString().contains("file_id"), errors.toString()); + assertTrue(recorded.isEmpty()); + } + + @Test + public void s3AndSftpFileJobsRequireFileNameAndReturnUrl() throws IOException { + mockAll(200, "{}"); + List errors = new ArrayList<>(); + ZeroBounceSDK.OnErrorCallback collect = error -> errors.addAll(error.getErrors()); + + // LIVE: both fields are required on every one of these jobs. + zeroBounceSDK.emailFinderS3File( + new ZBBulkFileJobOptions().setReturnUrl("https://example.com/done"), + r -> fail("success"), collect); + zeroBounceSDK.validateSFTPFile( + new ZBBulkFileJobOptions().setReturnUrl("https://example.com/done"), + r -> fail("success"), collect); + assertTrue(errors.toString().contains("file_name"), errors.toString()); + + errors.clear(); + zeroBounceSDK.domainSearchS3File( + new ZBBulkFileJobOptions().setFileName("f.csv"), r -> fail("success"), collect); + zeroBounceSDK.scoreSFTPFile( + new ZBBulkFileJobOptions().setFileName("f.csv"), r -> fail("success"), collect); + assertTrue(errors.toString().contains("return_url"), errors.toString()); + assertTrue(recorded.isEmpty()); + } + @Test public void inboxPlacementUsesItsOwnHostAndCamelCaseJson() throws IOException { mockAll(200, "{\"ok\":true}"); @@ -469,8 +572,10 @@ public void inboxPlacementUsesItsOwnHostAndCamelCaseJson() throws IOException { }, this::failOnError); Recorded record = only(); assertEquals("https://bulkapi.zerobounce.net/api/ipt/providers", record.path()); - // The API key goes on the query string here, not in the body. - assertEquals(API_KEY, record.query().get("api_key")); + // LIVE: the Inbox Placement Tester authenticates with a Bearer header, + // and the key must not reach the query string, where it would be logged. + assertEquals("Bearer " + API_KEY, record.headers.get("Authorization")); + assertNull(record.query().get("api_key")); zeroBounceSDK.getInboxPlacementTestTypes(r -> { }, this::failOnError); 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 9d28c81..64958e3 100644 --- a/zero-bounce-sdk/src/test/java/com/zerobounce/ZeroBounceSDKTest.java +++ b/zero-bounce-sdk/src/test/java/com/zerobounce/ZeroBounceSDKTest.java @@ -187,7 +187,7 @@ private void assertSubStatusParsed( public void validateEmail_ReturnsError() throws Exception { // Prepare mock response and add it to the server String responseJson = "{\"error\":[\"Invalid API Key or your account ran out of credits\"]}"; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String email = "flowerjill@aol.com"; String urlPath = getEncodedUrl( @@ -319,7 +319,7 @@ public void validateBatchEmails_ReturnsError() throws Exception { } ] }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); List emailsData = new ArrayList<>(); emailsData.add(new ZBValidateBatchData("valid@example.com", "1.1.1.1")); @@ -396,7 +396,7 @@ public void guessFormat_ReturnsSuccess() throws Exception { public void guessFormat_ReturnsError() throws Exception { // Prepare mock response and add it to the server String responseJson = "{\"error\":[\"Invalid API Key or your account ran out of credits\"]}"; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String domain = "example.com"; String urlPath = getEncodedUrl( @@ -472,7 +472,7 @@ public void findEmail_ReturnsSuccess() throws Exception { public void findEmail_ReturnsError() throws Exception { // Prepare mock response and add it to the server String responseJson = "{\"error\":[\"Invalid API Key or your account ran out of credits\"]}"; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String domain = "example.com"; String firstName = "John doe"; @@ -590,7 +590,7 @@ public void findDomain_ReturnsSuccess() throws Exception { public void findDomain_ReturnsError() throws Exception { // Prepare mock response and add it to the server String responseJson = "{\"error\":[\"Invalid API Key or your account ran out of credits\"]}"; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String domain = "example.com"; String urlPath = getEncodedUrl( @@ -672,7 +672,7 @@ public void getCredits_ReturnsSuccess() throws Exception { public void getCredits_ReturnsError() throws Exception { // Prepare mock response and add it to the server String responseJson = "{\"Credits\":-1}"; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String urlPath = getEncodedUrl( "https://api.zerobounce.net/v2/getcredits", @@ -771,7 +771,7 @@ public void getApiUsage_ReturnSuccess() throws Exception { public void getApiUsage_ReturnsError() throws Exception { // Prepare mock response and add it to the server String responseJson = "{\"error\":\"Invalid API Key\"}"; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); Date startDate = new Date(); startDate.setTime(startDate.getTime() - 5 * 24 * 60 * 60 * 1000); // previous 5 days @@ -1072,7 +1072,7 @@ public void sendFile_ReturnsError() throws Exception { "Error messages" ] }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); File file = new File("../email_file.csv"); String urlPath = "https://bulkapi.zerobounce.net/v2/sendfile"; @@ -1103,7 +1103,7 @@ public void scoringSendFile_ReturnsError() throws Exception { "Error messages" ] }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); File file = new File("../email_file.csv"); String urlPath = "https://bulkapi.zerobounce.net/v2/scoring/sendfile"; @@ -1130,7 +1130,7 @@ public void fileStatus_ReturnsError() throws Exception { "success": false, "message": "Error messages" }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String fileId = "some-id"; String urlPath = getEncodedUrl( @@ -1163,7 +1163,7 @@ public void scoringFileStatus_ReturnsError() throws Exception { "success": false, "message": "Error messages" }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String fileId = "some-id"; String urlPath = getEncodedUrl( @@ -1196,7 +1196,7 @@ public void getFile_ReturnsError() throws Exception { "success": false, "message": "Error messages" }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String fileId = "some-id"; String urlPath = getEncodedUrl( @@ -1230,7 +1230,7 @@ public void scoringGetFile_ReturnsError() throws Exception { "success": false, "message": "Error messages" }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String fileId = "some-id"; String urlPath = getEncodedUrl( @@ -1264,7 +1264,7 @@ public void deleteFile_ReturnsError() throws Exception { "success": false, "message": "File cannot be found." }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String fileId = "some-id"; String urlPath = getEncodedUrl( @@ -1297,7 +1297,7 @@ public void scoringDeleteFile_ReturnsError() throws Exception { "success": false, "message": "File cannot be found." }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String fileId = "some-id"; String urlPath = getEncodedUrl( @@ -1363,7 +1363,7 @@ public void getActivityData_ReturnsError() throws Exception { "found": false, "active_in_days": null }"""; - ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson); + ErrorResponse expectedResponse = ErrorResponse.parseError(responseJson, 400); String email = "flowerjill@aol.com"; String urlPath = getEncodedUrl( From 735ab22f6c5ea03a5de2ef0ec691046171689c63 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:08:48 +0400 Subject: [PATCH 4/7] feat: add a Warmup API client The Warmup API is a separate product: its own host, its own team-scoped key, and an API-Key header rather than an api_key parameter. ZeroBounceSDK stays the validation client; ZeroBounceWarmup covers all 26 documented endpoints, including the two CSV downloads. Two live findings are recorded at the call site: check-domain-dmarc takes an email address, not the domain the docs describe, and a failed SMTP login comes back as an HTTP 500. The key is masked out of every message, and the SMTP username and password out of errors from that method. Co-Authored-By: Claude Opus 5 --- .../java/com/zerobounce/ZeroBounceWarmup.java | 979 ++++++++++++++++++ .../com/zerobounce/OfflineWarmupTest.java | 591 +++++++++++ 2 files changed, 1570 insertions(+) create mode 100644 zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceWarmup.java create mode 100644 zero-bounce-sdk/src/test/java/com/zerobounce/OfflineWarmupTest.java diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceWarmup.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceWarmup.java new file mode 100644 index 0000000..d7f67c2 --- /dev/null +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceWarmup.java @@ -0,0 +1,979 @@ +package com.zerobounce; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Client for the ZeroBounce Warmup API + * (docs). + *

+ * The Warmup API is separate from the email validation API: its own host, its + * own team-scoped key, and an {@code API-Key} header instead of an + * {@code api_key} parameter. Use {@code ZeroBounceWarmup} with a Warmup API + * key; {@link ZeroBounceSDK} stays for the validation API key. + *

+ * Every method answers through a callback, like {@link ZeroBounceSDK}. + * Failures carry the HTTP status on {@link ErrorResponse#getStatusCode()}. + */ +public class ZeroBounceWarmup { + + public static final String DEFAULT_BASE_URL = "https://warmup-api.zerobounce.net"; + private static final String BASE_PATH = "/api/v1/warmup"; + private static final int DEFAULT_TIMEOUT_MILLIS = 120_000; + + /** Required by the schema but always replaced with the key's own team. */ + private static final int ANY_TEAM_ID = 1; + + private static final List ONBOARDING_MODELS = Arrays.asList("self_managed", "zb_managed"); + private static final List EMAIL_TYPES = + Arrays.asList("transactional", "newsletter", "marketing"); + + private static final ZeroBounceWarmup instance = new ZeroBounceWarmup(); + + private final Gson gson = new GsonBuilder().disableHtmlEscaping().create(); + + private String apiKey; + private String root = DEFAULT_BASE_URL + BASE_PATH; + private int timeoutInMillis = DEFAULT_TIMEOUT_MILLIS; + /** Why the client is unusable, or null when it is ready. */ + private String setupProblem = "ZeroBounceWarmup is not initialized. Call initialize(\"API_KEY\") first"; + + ZeroBounceWarmup() { + } + + public static ZeroBounceWarmup getInstance() { + return instance; + } + + /** A client on the default Warmup host. */ + public void initialize(@NotNull String apiKey) { + initialize(apiKey, DEFAULT_BASE_URL); + } + + /** + * A client on a custom https base URL. + *

+ * A key with surrounding whitespace or control characters, or a base URL + * that is not https, is rejected here: every later call then fails with + * that message rather than sending the key anywhere. + */ + public void initialize(@NotNull String apiKey, @NotNull String baseUrl) { + this.apiKey = apiKey; + if (apiKey.trim().isEmpty()) { + setupProblem = "api_key is required"; + return; + } + // A key with surrounding whitespace or control characters would be + // quoted, unredacted, in the transport's own error. + if (!apiKey.equals(apiKey.trim()) || hasControlCharacters(apiKey)) { + setupProblem = "api_key has surrounding whitespace or control characters"; + return; + } + if (!baseUrl.regionMatches(true, 0, "https://", 0, 8)) { + setupProblem = "base_url must be an https:// URL"; + return; + } + String trimmed = baseUrl; + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + root = trimmed + BASE_PATH; + setupProblem = null; + } + + /** The request timeout in milliseconds, 0 for none. */ + public void setTimeout(int timeoutInMillis) { + this.timeoutInMillis = timeoutInMillis; + } + + private static boolean hasControlCharacters(String value) { + for (int i = 0; i < value.length(); i++) { + if (Character.isISOControl(value.charAt(i))) { + return true; + } + } + return false; + } + + // -- transport ----------------------------------------------------------- + + /** Replace every form of a secret in a message with ***. */ + private String redact(@Nullable String text, String... secrets) { + if (text == null) { + return ""; + } + String value = text; + if (apiKey != null && !apiKey.isEmpty()) { + value = value.replace(apiKey, "***"); + } + for (String secret : secrets) { + if (secret != null && !secret.isEmpty()) { + value = value.replace(secret, "***"); + } + } + return value; + } + + /** Separated like ZeroBounceSDK.createUrlFrom so tests never touch the network. */ + URL createUrlFrom(String url) throws MalformedURLException { + return URI.create(url).toURL(); + } + + private boolean notReady(@NotNull ZeroBounceSDK.OnErrorCallback errorCallback) { + if (setupProblem == null) { + return false; + } + errorCallback.onError(ErrorResponse.parseError(setupProblem)); + return true; + } + + /** What one HTTP exchange produced. */ + private static class Result { + int status; + String contentType = ""; + String body = ""; + } + + private @Nullable Result send( + @NotNull String method, + @NotNull String url, + @Nullable String jsonBody, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback, + String... secrets + ) { + try { + HttpURLConnection con = (HttpURLConnection) createUrlFrom(url).openConnection(); + con.setRequestMethod(method); + con.setRequestProperty("Accept", "application/json"); + con.setRequestProperty("API-Key", apiKey); + con.setConnectTimeout(timeoutInMillis); + con.setReadTimeout(timeoutInMillis); + con.setInstanceFollowRedirects(false); + + if (jsonBody != null) { + byte[] out = jsonBody.getBytes(StandardCharsets.UTF_8); + con.setRequestProperty("Content-Type", "application/json"); + con.setDoOutput(true); + con.setFixedLengthStreamingMode(out.length); + con.connect(); + try (OutputStream os = con.getOutputStream()) { + os.write(out); + } + } + + Result result = new Result(); + result.status = con.getResponseCode(); + String contentType = con.getHeaderField("Content-Type"); + result.contentType = contentType != null ? contentType : ""; + + Reader streamReader = result.status > 299 + ? new InputStreamReader(con.getErrorStream(), StandardCharsets.UTF_8) + : new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8); + StringBuilder content = new StringBuilder(); + try (BufferedReader in = new BufferedReader(streamReader)) { + String line; + while ((line = in.readLine()) != null) { + content.append(line); + } + } + con.disconnect(); + result.body = content.toString(); + + if (result.status >= 300 && result.status < 400) { + errorCallback.onError(ErrorResponse.parseError( + "ZeroBounce returned a redirect, which this client does not follow.", + result.status)); + return null; + } + if (result.status > 299) { + errorCallback.onError(ErrorResponse.parseError( + redact(messageFromBody(result.body, result.status), secrets), result.status)); + return null; + } + return result; + } catch (Exception e) { + // Rebuilt rather than wrapped: the original holds the URL and headers. + errorCallback.onError(ErrorResponse.parseError(redact(e.getMessage(), secrets))); + return null; + } + } + + /** + * The JSON payload of a success. A documented empty success (204, or an + * empty 200) is handed to the caller as {@code null}. + */ + private void json( + @NotNull String method, + @NotNull String url, + @Nullable String jsonBody, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback, + String... secrets + ) { + Result result = send(method, url, jsonBody, errorCallback, secrets); + if (result == null) { + return; + } + if (result.status == 204 || result.body.trim().isEmpty()) { + successCallback.onSuccess(null); + return; + } + try { + // A list payload is wrapped so one return type covers every endpoint. + com.google.gson.JsonElement parsed = JsonParser.parseString(result.body); + if (parsed.isJsonArray()) { + JsonObject wrapper = new JsonObject(); + wrapper.add("data", parsed); + successCallback.onSuccess(wrapper); + return; + } + successCallback.onSuccess(parsed.getAsJsonObject()); + } catch (RuntimeException e) { + errorCallback.onError(ErrorResponse.parseError( + redact(messageFromBody(result.body, result.status), secrets), result.status)); + } + } + + /** A CSV download, returned as text. */ + private void csv( + @NotNull String url, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + Result result = send("GET", url, null, errorCallback); + if (result == null) { + return; + } + if (result.body.trim().isEmpty() + || result.contentType.toLowerCase(java.util.Locale.ROOT).contains("application/json")) { + errorCallback.onError(ErrorResponse.parseError( + redact(messageFromBody(result.body, result.status)), result.status)); + return; + } + successCallback.onSuccess(result.body); + } + + /** Turn an error body into a short message. Never raw HTML or a parser dump. */ + private String messageFromBody(@Nullable String body, int status) { + String trimmed = body == null ? "" : body.trim(); + if (trimmed.isEmpty()) { + return "Empty response from ZeroBounce (HTTP " + status + ")"; + } + String head = trimmed.substring(0, Math.min(2000, trimmed.length())) + .toLowerCase(java.util.Locale.ROOT); + if (head.contains(" 1) { + StringBuilder path = new StringBuilder(); + com.google.gson.JsonArray loc = detail.getAsJsonArray("loc"); + for (int i = 1; i < loc.size(); i++) { + if (path.length() > 0) path.append('.'); + path.append(loc.get(i).getAsString()); + } + field = path.toString(); + } + String msg = detail.get("msg").getAsString(); + return field.isEmpty() ? msg : field + ": " + msg; + } + } + } + return "ZeroBounce returned an error (HTTP " + status + ")."; + } catch (RuntimeException e) { + 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 trimmed; + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + /** URL-encodes a value as a single path segment, so a slash in it stays encoded. */ + private static String encodePathSegment(String value) { + return encode(value).replace("+", "%20"); + } + + private String url(String path, Map query) { + StringBuilder url = new StringBuilder(root).append(path); + String separator = "?"; + for (Map.Entry parameter : query.entrySet()) { + url.append(separator).append(encode(parameter.getKey())).append('=') + .append(encode(parameter.getValue())); + separator = "&"; + } + return url.toString(); + } + + // -- argument checks ----------------------------------------------------- + + private boolean missing( + @Nullable String value, String name, @NotNull ZeroBounceSDK.OnErrorCallback errorCallback) { + if (value == null || value.trim().isEmpty()) { + errorCallback.onError(ErrorResponse.parseError(name + " is required")); + return true; + } + return false; + } + + private boolean outOfRange( + int value, int low, int high, String name, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback) { + if (value < low || value > high) { + errorCallback.onError( + ErrorResponse.parseError(name + " must be " + low + " to " + high)); + return true; + } + return false; + } + + private static boolean isIsoDate(@Nullable String value) { + return value != null && value.matches("\\d{4}-\\d{2}-\\d{2}"); + } + + // -- sending accounts ---------------------------------------------------- + + /** Every sending account of the key's team. GET /sending-accounts */ + public void listSendingAccounts( + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + json("GET", url("/sending-accounts", Map.of()), null, successCallback, errorCallback); + } + + /** One sending account. GET /sending-account */ + public void getSendingAccount( + @NotNull String fromAddress, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(fromAddress, "from_address", errorCallback)) return; + json("GET", url("/sending-account", Map.of("from_address", fromAddress)), + null, successCallback, errorCallback); + } + + /** + * Add a from-address to warmup. POST /sending-accounts + *

+ * {@code self_managed} runs your own mailbox and needs + * {@link AddSendingAccountOptions#setEngagementRuleId(Integer)} 0-4. + * {@code zb_managed} has ZeroBounce provision it and takes + * {@code emailContent} and {@code managedSending}; a 200 means accepted, + * and the mailbox is provisioned afterwards. + */ + public void addSendingAccount( + @NotNull String fromAddress, + @NotNull String onboardingModel, + @Nullable AddSendingAccountOptions options, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(fromAddress, "from_address", errorCallback)) return; + if (!ONBOARDING_MODELS.contains(onboardingModel)) { + errorCallback.onError( + ErrorResponse.parseError("onboarding_model must be self_managed or zb_managed")); + return; + } + + Map body = new LinkedHashMap<>(); + body.put("from_address", fromAddress); + body.put("onboarding_model", onboardingModel); + AddSendingAccountOptions set = options != null ? options : new AddSendingAccountOptions(); + + if ("self_managed".equals(onboardingModel)) { + if (set.engagementRuleId == null) { + errorCallback.onError( + ErrorResponse.parseError("engagement_rule_id is required for self_managed")); + return; + } + if (outOfRange(set.engagementRuleId, 0, 4, "engagement_rule_id", errorCallback)) return; + body.put("engagement_rule_id", set.engagementRuleId); + if (set.emailContent != null || set.managedSending != null) { + errorCallback.onError( + ErrorResponse.parseError("email_content and managed_sending are for zb_managed")); + return; + } + } + if (set.ignoreConflict != null) { + body.put("ignore_conflict", set.ignoreConflict); + } + if (set.emailContent != null) { + Map content = new LinkedHashMap<>(); + if (set.emailContent.typesOfEmails != null && !set.emailContent.typesOfEmails.isEmpty()) { + for (String kind : set.emailContent.typesOfEmails) { + if (!EMAIL_TYPES.contains(kind)) { + errorCallback.onError(ErrorResponse.parseError( + "types_of_emails must be transactional, newsletter and/or marketing")); + return; + } + } + content.put("types_of_emails", set.emailContent.typesOfEmails); + } + if (set.emailContent.industry != null) content.put("industry", set.emailContent.industry); + if (set.emailContent.companyName != null) content.put("company_name", set.emailContent.companyName); + if (set.emailContent.website != null) content.put("website", set.emailContent.website); + body.put("email_content", content); + } + if (set.managedSending != null) { + Map sending = new LinkedHashMap<>(); + if (set.managedSending.timezoneOffset != null) { + if (outOfRange(set.managedSending.timezoneOffset, -11, 14, "timezone_offset", errorCallback)) { + return; + } + sending.put("timezone_offset", set.managedSending.timezoneOffset); + } + if (set.managedSending.warmupStartDate != null) { + if (!isIsoDate(set.managedSending.warmupStartDate)) { + errorCallback.onError(ErrorResponse.parseError("warmup_start_date must be YYYY-MM-DD")); + return; + } + sending.put("warmup_start_date", set.managedSending.warmupStartDate); + } + if (set.managedSending.dailyLimit != null) { + sending.put("daily_limit", set.managedSending.dailyLimit); + } + body.put("managed_sending", sending); + } + + json("POST", url("/sending-accounts", Map.of()), gson.toJson(body), + successCallback, errorCallback); + } + + /** + * Change an account's engagement rule, status and auto-enable flag. + * PUT /sending-accounts. {@code statusId} is 0 to 8 (4 Paused, 5 Disabled). + */ + public void updateSendingAccount( + @NotNull String fromAddress, + int engagementRuleId, + int statusId, + boolean autoEnable, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(fromAddress, "from_address", errorCallback)) return; + if (outOfRange(engagementRuleId, 0, 4, "engagement_rule_id", errorCallback)) return; + if (outOfRange(statusId, 0, 8, "status_id", errorCallback)) return; + + Map body = new LinkedHashMap<>(); + body.put("team_id", ANY_TEAM_ID); + body.put("from_address", fromAddress); + body.put("engagement_rule_id", engagementRuleId); + body.put("status_id", statusId); + body.put("auto_enable", autoEnable); + json("PUT", url("/sending-accounts", Map.of()), gson.toJson(body), + successCallback, errorCallback); + } + + /** Remove a sending account. DELETE /sending-accounts. Destructive and final. */ + public void deleteSendingAccount( + @NotNull String fromAddress, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(fromAddress, "from_address", errorCallback)) return; + json("DELETE", url("/sending-accounts", Map.of("from_address", fromAddress)), + null, successCallback, errorCallback); + } + + /** + * Move an account and its history to a new from-address. + * PUT /smtp-credentials - despite the path, no SMTP credentials are stored. + */ + public void changeFromAddress( + @NotNull String oldFromAddress, + @NotNull String newFromAddress, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) + || missing(oldFromAddress, "old_from_address", errorCallback) + || missing(newFromAddress, "new_from_address", errorCallback)) { + return; + } + Map body = new LinkedHashMap<>(); + body.put("team_id", ANY_TEAM_ID); + body.put("old_from_address", oldFromAddress); + body.put("new_from_address", newFromAddress); + json("PUT", url("/smtp-credentials", Map.of()), gson.toJson(body), + successCallback, errorCallback); + } + + /** + * Registered from-addresses. GET /from-addresses + *

+ * {@code searchFrom} takes letters, digits and {@code . - _ @ +} only. + * {@code offset} and {@code limit} go on the wire as strings. + */ + public void listFromAddresses( + @Nullable String searchFrom, + @Nullable Integer offset, + @Nullable Integer limit, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + Map query = new LinkedHashMap<>(); + if (searchFrom != null) { + if (!searchFrom.matches("[A-Za-z0-9.\\-_@+]*")) { + errorCallback.onError(ErrorResponse.parseError( + "search_from takes letters, digits and . - _ @ + only")); + return; + } + query.put("search_from", searchFrom); + } + if (offset != null) { + if (offset < 0) { + errorCallback.onError(ErrorResponse.parseError("offset must be a non-negative integer")); + return; + } + query.put("offset", String.valueOf(offset)); + } + if (limit != null) { + if (limit < 0) { + errorCallback.onError(ErrorResponse.parseError("limit must be a non-negative integer")); + return; + } + query.put("limit", String.valueOf(limit)); + } + json("GET", url("/from-addresses", query), null, successCallback, errorCallback); + } + + // -- engagement rules ---------------------------------------------------- + + /** -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. */ + public void getEngagementRules( + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + json("GET", url("/engagement-rules", Map.of()), null, successCallback, errorCallback); + } + + /** Set a standard rule (0 to 4) on an address. PUT /engagement-rule */ + public void setEngagementRule( + @NotNull String cFrom, + int ruleId, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(cFrom, "c_from", errorCallback)) return; + if (outOfRange(ruleId, 0, 4, "rule_id", errorCallback)) return; + json("PUT", url("/engagement-rule", Map.of("c_from", cFrom)), + gson.toJson(Map.of("rule_id", ruleId)), successCallback, errorCallback); + } + + /** An address's custom rule, with can_update and remaining_seconds. */ + public void getCustomEngagement( + @NotNull String cFrom, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(cFrom, "c_from", errorCallback)) return; + String segment = cFrom.trim(); + if (segment.equals(".") || segment.equals("..")) { + errorCallback.onError(ErrorResponse.parseError("c_from is invalid")); + return; + } + json("GET", url("/custom-engagement/" + encodePathSegment(segment), Map.of()), + null, successCallback, errorCallback); + } + + /** Create a custom rule. Starts a 7 day cooldown on updates. */ + public void createCustomEngagement( + @NotNull String cFrom, int replyPct, int importantPct, int clickPct, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + String body = engagementBody(cFrom, replyPct, importantPct, clickPct, errorCallback); + if (body == null) return; + json("POST", url("/custom-engagement", Map.of()), body, successCallback, errorCallback); + } + + /** Update a custom rule. During the 7 day cooldown the API answers 429. */ + public void updateCustomEngagement( + @NotNull String cFrom, int replyPct, int importantPct, int clickPct, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + String body = engagementBody(cFrom, replyPct, importantPct, clickPct, errorCallback); + if (body == null) return; + json("PUT", url("/custom-engagement", Map.of()), body, successCallback, errorCallback); + } + + private @Nullable String engagementBody( + String cFrom, int replyPct, int importantPct, int clickPct, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (missing(cFrom, "c_from", errorCallback)) return null; + if (outOfRange(replyPct, 5, 45, "reply_pct", errorCallback)) return null; + if (outOfRange(importantPct, 5, 40, "important_pct", errorCallback)) return null; + if (outOfRange(clickPct, 0, 70, "click_pct", errorCallback)) return null; + if (clickPct > (int) (importantPct * 1.75)) { + errorCallback.onError( + ErrorResponse.parseError("click_pct must be at most important_pct x 1.75")); + return null; + } + Map body = new LinkedHashMap<>(); + body.put("c_from", cFrom); + body.put("reply_pct", replyPct); + body.put("important_pct", importantPct); + body.put("click_pct", clickPct); + return gson.toJson(body); + } + + /** Delete a custom rule and fall back to standard rule 1-4. */ + public void deleteCustomEngagement( + @NotNull String cFrom, + int engagementRuleId, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(cFrom, "c_from", errorCallback)) return; + if (outOfRange(engagementRuleId, 1, 4, "engagement_rule_id", errorCallback)) return; + Map body = new LinkedHashMap<>(); + body.put("c_from", cFrom); + body.put("engagement_rule_id", engagementRuleId); + json("DELETE", url("/custom-engagement", Map.of()), gson.toJson(body), + successCallback, errorCallback); + } + + // -- statistics, seeds and utilities ------------------------------------- + + /** The seed list. GET /seeds */ + public void getSeeds( + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + json("GET", url("/seeds", Map.of()), null, successCallback, errorCallback); + } + + /** The seed list as CSV (columns seed,provider). GET /seeds/download */ + public void downloadSeeds( + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + csv(url("/seeds/download", Map.of()), successCallback, errorCallback); + } + + /** Number of seeds. GET /seed-count */ + public void getSeedCount( + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + json("GET", url("/seed-count", Map.of()), null, successCallback, errorCallback); + } + + /** Today's warmup progress. GET /progress-today */ + public void getProgressToday( + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + json("GET", url("/progress-today", Map.of()), null, successCallback, errorCallback); + } + + /** Messages sent today. GET /sent-today */ + public void getSentToday( + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + json("GET", url("/sent-today", Map.of()), null, successCallback, errorCallback); + } + + /** Current daily volume. GET /emails-per-day */ + public void getEmailsPerDay( + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback)) return; + json("GET", url("/emails-per-day", Map.of()), null, successCallback, errorCallback); + } + + /** Statistics for an address. An address with no history returns zeros. */ + public void getStatistics( + @NotNull String fromAddress, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(fromAddress, "from_address", errorCallback)) return; + json("GET", url("/statistics", Map.of("from_address", fromAddress)), + null, successCallback, errorCallback); + } + + /** Daily statistics between two dates, inclusive. GET /statistics-per-day */ + public void getStatisticsPerDay( + @NotNull String fromAddress, + @NotNull String startDate, + @NotNull String endDate, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(fromAddress, "from_address", errorCallback)) return; + if (!isIsoDate(startDate)) { + errorCallback.onError(ErrorResponse.parseError("start_date must be YYYY-MM-DD")); + return; + } + if (!isIsoDate(endDate)) { + errorCallback.onError(ErrorResponse.parseError("end_date must be YYYY-MM-DD")); + return; + } + if (startDate.compareTo(endDate) > 0) { + errorCallback.onError(ErrorResponse.parseError("start_date is later than end_date")); + return; + } + Map query = new LinkedHashMap<>(); + query.put("from_address", fromAddress); + query.put("start_date", startDate); + query.put("end_date", endDate); + json("GET", url("/statistics-per-day", query), null, successCallback, errorCallback); + } + + /** Detailed statistics for an address. GET /statistics-detailed */ + public void getStatisticsDetailed( + @NotNull String cFrom, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(cFrom, "c_from", errorCallback)) return; + json("GET", url("/statistics-detailed", Map.of("c_from", cFrom)), + null, successCallback, errorCallback); + } + + /** An address's statistics as CSV. GET /statistics/download */ + public void downloadStatistics( + @NotNull String cFrom, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(cFrom, "c_from", errorCallback)) return; + csv(url("/statistics/download", Map.of("c_from", cFrom)), successCallback, errorCallback); + } + + /** Statistics for one from-address. GET /from-address-statistics */ + public void getFromAddressStatistics( + @NotNull String fromAddress, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(fromAddress, "from_address", errorCallback)) return; + json("GET", url("/from-address-statistics", Map.of("from_address", fromAddress)), + null, successCallback, errorCallback); + } + + /** + * SPF, DKIM and DMARC status of a from-address's domain. + * GET /check-domain-dmarc + *

+ * LIVE: pass an email address. The live API answers "Please + * provide a valid email address" for a bare domain, although the docs + * describe a domain. + */ + public void checkDomainDmarc( + @NotNull String fromAddress, + @Nullable String dkimSelector, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) || missing(fromAddress, "from_address", errorCallback)) return; + if (!fromAddress.contains("@")) { + errorCallback.onError(ErrorResponse.parseError( + "from_address must be an email address, e.g. sender@example.com")); + return; + } + Map query = new LinkedHashMap<>(); + query.put("from_address", fromAddress); + if (dkimSelector != null) { + if (missing(dkimSelector, "dkim_selector", errorCallback)) return; + query.put("dkim_selector", dkimSelector); + } + json("GET", url("/check-domain-dmarc", query), null, successCallback, errorCallback); + } + + /** + * Check that SMTP credentials connect and hand off a message. + * POST /test-smtp-configuration + *

+ * Nothing is stored, and the test message goes to a ZeroBounce test + * recipient, not {@code fromAddress}. + *

+ * LIVE: the API answers any failed SMTP login with HTTP 500. That is an API + * bug. The username and password are masked in every error from here. + */ + public void testSmtpConfiguration( + @NotNull String fromAddress, + @NotNull String username, + @NotNull String password, + @NotNull String server, + int port, + @NotNull ZeroBounceSDK.OnSuccessCallback successCallback, + @NotNull ZeroBounceSDK.OnErrorCallback errorCallback + ) { + if (notReady(errorCallback) + || missing(fromAddress, "from_address", errorCallback) + || missing(username, "username", errorCallback) + || missing(password, "password", errorCallback) + || missing(server, "server", errorCallback)) { + return; + } + if (outOfRange(port, 1, 65535, "port", errorCallback)) return; + + Map body = new LinkedHashMap<>(); + body.put("from_address", fromAddress); + body.put("username", username); + body.put("password", password); + body.put("server", server); + body.put("port", port); + json("POST", url("/test-smtp-configuration", Map.of()), gson.toJson(body), + successCallback, errorCallback, password, username); + } + + // -- option holders ------------------------------------------------------ + + /** What ZeroBounce should send on your behalf, for zb_managed onboarding. */ + public static class EmailContent { + @Nullable List typesOfEmails; + @Nullable String industry; + @Nullable String companyName; + @Nullable String website; + + /** Any of transactional, newsletter, marketing. */ + public EmailContent setTypesOfEmails(@Nullable List typesOfEmails) { + this.typesOfEmails = typesOfEmails; + return this; + } + + public EmailContent setIndustry(@Nullable String industry) { + this.industry = industry; + return this; + } + + public EmailContent setCompanyName(@Nullable String companyName) { + this.companyName = companyName; + return this; + } + + public EmailContent setWebsite(@Nullable String website) { + this.website = website; + return this; + } + } + + /** When ZeroBounce should send, for zb_managed onboarding. */ + public static class ManagedSending { + @Nullable Integer timezoneOffset; + @Nullable String warmupStartDate; + @Nullable Integer dailyLimit; + + /** -11 to 14. */ + public ManagedSending setTimezoneOffset(@Nullable Integer timezoneOffset) { + this.timezoneOffset = timezoneOffset; + return this; + } + + /** YYYY-MM-DD. */ + public ManagedSending setWarmupStartDate(@Nullable String warmupStartDate) { + this.warmupStartDate = warmupStartDate; + return this; + } + + public ManagedSending setDailyLimit(@Nullable Integer dailyLimit) { + this.dailyLimit = dailyLimit; + return this; + } + } + + /** The optional arguments of {@link #addSendingAccount}. */ + public static class AddSendingAccountOptions { + @Nullable Integer engagementRuleId; + @Nullable Boolean ignoreConflict; + @Nullable EmailContent emailContent; + @Nullable ManagedSending managedSending; + + /** Required for self_managed: 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. */ + public AddSendingAccountOptions setEngagementRuleId(@Nullable Integer engagementRuleId) { + this.engagementRuleId = engagementRuleId; + return this; + } + + public AddSendingAccountOptions setIgnoreConflict(@Nullable Boolean ignoreConflict) { + this.ignoreConflict = ignoreConflict; + return this; + } + + /** zb_managed only. */ + public AddSendingAccountOptions setEmailContent(@Nullable EmailContent emailContent) { + this.emailContent = emailContent; + return this; + } + + /** zb_managed only. */ + public AddSendingAccountOptions setManagedSending(@Nullable ManagedSending managedSending) { + this.managedSending = managedSending; + return this; + } + } +} diff --git a/zero-bounce-sdk/src/test/java/com/zerobounce/OfflineWarmupTest.java b/zero-bounce-sdk/src/test/java/com/zerobounce/OfflineWarmupTest.java new file mode 100644 index 0000000..06dbcf0 --- /dev/null +++ b/zero-bounce-sdk/src/test/java/com/zerobounce/OfflineWarmupTest.java @@ -0,0 +1,591 @@ +package com.zerobounce; + +import com.google.gson.JsonObject; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Offline tests for the Warmup API client. Every request is answered by a + * mocked {@link HttpURLConnection}; nothing here reaches the live API. The + * assertions are on what actually went on the wire. + */ +@ExtendWith(MockitoExtension.class) +public class OfflineWarmupTest { + + @Spy + private ZeroBounceWarmup warmup; + + private static final String API_KEY = "warmup-key"; + + /** What one mocked call recorded. */ + private static class Recorded { + String url; + String method; + String body; + final Map headers = new LinkedHashMap<>(); + + String path() { + int mark = url.indexOf('?'); + return mark < 0 ? url : url.substring(0, mark); + } + + Map query() { + Map params = new LinkedHashMap<>(); + int mark = url.indexOf('?'); + if (mark < 0) return params; + for (String pair : url.substring(mark + 1).split("&")) { + int split = pair.indexOf('='); + if (split < 0) continue; + params.put( + URLDecoder.decode(pair.substring(0, split), StandardCharsets.UTF_8), + URLDecoder.decode(pair.substring(split + 1), StandardCharsets.UTF_8) + ); + } + return params; + } + + JsonObject json() { + return com.google.gson.JsonParser.parseString(body).getAsJsonObject(); + } + } + + private final List recorded = new ArrayList<>(); + private final List errors = new ArrayList<>(); + private int lastStatus; + + @BeforeEach + public void setUp() { + warmup.initialize(API_KEY); + recorded.clear(); + errors.clear(); + lastStatus = -1; + } + + private void mockAll(int status, @NotNull String responseJson) throws IOException { + mockAll(status, responseJson, "application/json"); + } + + private void mockAll(int status, @NotNull String responseJson, @NotNull String contentType) + throws IOException { + ByteArrayOutputStream sentBody = new ByteArrayOutputStream(); + + URL url = mock(URL.class, + withSettings().useConstructor("https://warmup-api.zerobounce.net/api/v1/warmup/seeds")); + HttpURLConnection con = mock(HttpURLConnection.class); + lenient().when(url.openConnection()).thenReturn(con); + lenient().when(con.getResponseCode()).thenReturn(status); + lenient().when(con.getHeaderField("Content-Type")).thenReturn(contentType); + lenient().when(con.getOutputStream()).thenReturn(sentBody); + lenient().when(con.getInputStream()) + .thenAnswer(i -> new ByteArrayInputStream(responseJson.getBytes(StandardCharsets.UTF_8))); + lenient().when(con.getErrorStream()) + .thenAnswer(i -> new ByteArrayInputStream(responseJson.getBytes(StandardCharsets.UTF_8))); + + lenient().doAnswer(invocation -> { + Recorded record = new Recorded(); + record.url = invocation.getArgument(0); + recorded.add(record); + sentBody.reset(); + return url; + }).when(warmup).createUrlFrom(anyString()); + + lenient().doAnswer(invocation -> { + if (!recorded.isEmpty()) { + recorded.get(recorded.size() - 1).method = invocation.getArgument(0); + } + return null; + }).when(con).setRequestMethod(anyString()); + + lenient().doAnswer(invocation -> { + if (!recorded.isEmpty()) { + recorded.get(recorded.size() - 1).headers + .put(invocation.getArgument(0), invocation.getArgument(1)); + } + return null; + }).when(con).setRequestProperty(anyString(), anyString()); + + // The body is written after setRequestMethod, so read it back lazily. + lenient().doAnswer(invocation -> { + if (!recorded.isEmpty()) { + recorded.get(recorded.size() - 1).body = sentBody.toString(StandardCharsets.UTF_8); + } + return null; + }).when(con).disconnect(); + } + + private Recorded only() { + assertEquals(1, recorded.size(), "expected exactly one request"); + Recorded record = recorded.get(0); + recorded.clear(); + return record; + } + + private final ZeroBounceSDK.OnSuccessCallback succeed = r -> { + }; + private final ZeroBounceSDK.OnSuccessCallback succeedText = r -> { + }; + + private void failOnError(ErrorResponse error) { + fail(error.getErrors().toString()); + } + + /** Collects an error, and asserts it stayed short. */ + private final ZeroBounceSDK.OnErrorCallback collect = error -> { + errors.addAll(error.getErrors()); + lastStatus = error.getStatusCode(); + assertTrue(error.getErrors().toString().length() < 500, error.getErrors().toString()); + }; + + private final ZeroBounceSDK.OnSuccessCallback failOnSuccess = r -> fail("success"); + + // -- construction -------------------------------------------------------- + + @Test + public void theKeyTravelsInTheApiKeyHeader() throws IOException { + mockAll(200, "{\"ok\":true}"); + + warmup.getSeedCount(succeed, this::failOnError); + Recorded record = only(); + assertEquals("https://warmup-api.zerobounce.net/api/v1/warmup/seed-count", record.path()); + assertEquals(API_KEY, record.headers.get("API-Key")); + // The key must never reach the query string, where it would be logged. + assertTrue(record.query().isEmpty(), record.url); + } + + @Test + public void aKeyThatIsEmptyOrPaddedIsRejected() throws IOException { + mockAll(200, "{}"); + for (String bad : new String[]{"", " key ", "key\n", "key\tx"}) { + warmup.initialize(bad); + errors.clear(); + warmup.getSeeds(failOnSuccess, collect); + assertTrue(errors.toString().contains("api_key"), errors.toString()); + } + assertTrue(recorded.isEmpty()); + } + + @Test + public void aPlainHttpBaseUrlIsRejected() throws IOException { + mockAll(200, "{}"); + warmup.initialize(API_KEY, "http://warmup-api.zerobounce.net"); + warmup.getSeeds(failOnSuccess, collect); + assertTrue(errors.toString().contains("https"), errors.toString()); + assertTrue(recorded.isEmpty()); + } + + @Test + public void aCustomHttpsBaseUrlIsUsed() throws IOException { + mockAll(200, "{}"); + warmup.initialize(API_KEY, "https://warmup.example.com/"); + warmup.getSeeds(succeed, this::failOnError); + assertEquals("https://warmup.example.com/api/v1/warmup/seeds", only().path()); + } + + // -- paths and verbs ----------------------------------------------------- + + @Test + public void everyEndpointUsesItsDocumentedPathAndVerb() throws IOException { + mockAll(200, "{\"ok\":true}"); + String root = "https://warmup-api.zerobounce.net/api/v1/warmup"; + + warmup.listSendingAccounts(succeed, this::failOnError); + assertEquals(root + "/sending-accounts", only().path()); + warmup.getSendingAccount("a@b.com", succeed, this::failOnError); + assertEquals(root + "/sending-account", only().path()); + warmup.listFromAddresses(null, null, null, succeed, this::failOnError); + assertEquals(root + "/from-addresses", only().path()); + warmup.getEngagementRules(succeed, this::failOnError); + assertEquals(root + "/engagement-rules", only().path()); + warmup.getCustomEngagement("a@b.com", succeed, this::failOnError); + assertEquals(root + "/custom-engagement/a%40b.com", only().path()); + warmup.getSeeds(succeed, this::failOnError); + assertEquals(root + "/seeds", only().path()); + warmup.getProgressToday(succeed, this::failOnError); + assertEquals(root + "/progress-today", only().path()); + warmup.getSentToday(succeed, this::failOnError); + assertEquals(root + "/sent-today", only().path()); + warmup.getEmailsPerDay(succeed, this::failOnError); + assertEquals(root + "/emails-per-day", only().path()); + warmup.getStatistics("a@b.com", succeed, this::failOnError); + assertEquals(root + "/statistics", only().path()); + warmup.getStatisticsDetailed("a@b.com", succeed, this::failOnError); + assertEquals(root + "/statistics-detailed", only().path()); + warmup.getFromAddressStatistics("a@b.com", succeed, this::failOnError); + assertEquals(root + "/from-address-statistics", only().path()); + warmup.checkDomainDmarc("a@b.com", null, succeed, this::failOnError); + assertEquals(root + "/check-domain-dmarc", only().path()); + + warmup.addSendingAccount("a@b.com", "self_managed", + new ZeroBounceWarmup.AddSendingAccountOptions().setEngagementRuleId(0), + succeed, this::failOnError); + Recorded added = only(); + assertEquals("POST", added.method); + assertEquals(root + "/sending-accounts", added.path()); + + warmup.updateSendingAccount("a@b.com", 1, 4, true, succeed, this::failOnError); + assertEquals("PUT", only().method); + warmup.deleteSendingAccount("a@b.com", succeed, this::failOnError); + assertEquals("DELETE", only().method); + warmup.changeFromAddress("a@b.com", "c@d.com", succeed, this::failOnError); + Recorded changed = only(); + assertEquals("PUT", changed.method); + assertEquals(root + "/smtp-credentials", changed.path()); + warmup.setEngagementRule("a@b.com", 2, succeed, this::failOnError); + Recorded rule = only(); + assertEquals("PUT", rule.method); + assertEquals(root + "/engagement-rule", rule.path()); + warmup.createCustomEngagement("a@b.com", 10, 10, 5, succeed, this::failOnError); + Recorded created = only(); + assertEquals("POST", created.method); + assertEquals(root + "/custom-engagement", created.path()); + warmup.updateCustomEngagement("a@b.com", 10, 10, 5, succeed, this::failOnError); + assertEquals("PUT", only().method); + warmup.deleteCustomEngagement("a@b.com", 1, succeed, this::failOnError); + assertEquals("DELETE", only().method); + warmup.testSmtpConfiguration("a@b.com", "u", "p", "smtp.example.com", 587, + succeed, this::failOnError); + Recorded smtp = only(); + assertEquals("POST", smtp.method); + assertEquals(root + "/test-smtp-configuration", smtp.path()); + } + + @Test + public void statisticsPerDaySendsBothDates() throws IOException { + mockAll(200, "{}"); + warmup.getStatisticsPerDay("a@b.com", "2026-01-02", "2026-01-09", succeed, this::failOnError); + Recorded record = only(); + assertEquals("2026-01-02", record.query().get("start_date")); + assertEquals("2026-01-09", record.query().get("end_date")); + } + + // -- argument validation ------------------------------------------------- + + @Test + public void requiredArgumentsAreCheckedBeforeAnyRequest() throws IOException { + mockAll(200, "{}"); + warmup.getSendingAccount("", failOnSuccess, collect); + warmup.deleteSendingAccount(" ", failOnSuccess, collect); + warmup.changeFromAddress("", "b@c.com", failOnSuccess, collect); + warmup.changeFromAddress("a@b.com", "", failOnSuccess, collect); + warmup.setEngagementRule("", 1, failOnSuccess, collect); + warmup.getStatistics("", failOnSuccess, collect); + warmup.checkDomainDmarc("", null, failOnSuccess, collect); + assertTrue(recorded.isEmpty(), "nothing should reach the network"); + } + + @Test + public void engagementRuleIdsAreRangeChecked() throws IOException { + mockAll(200, "{}"); + for (int bad : new int[]{-1, 5}) { + errors.clear(); + warmup.setEngagementRule("a@b.com", bad, failOnSuccess, collect); + assertTrue(errors.toString().contains("rule_id"), errors.toString()); + } + assertTrue(recorded.isEmpty()); + + for (int good = 0; good <= 4; good++) { + warmup.setEngagementRule("a@b.com", good, succeed, this::failOnError); + assertEquals(good, only().json().get("rule_id").getAsInt()); + } + } + + @Test + public void customEngagementPercentagesAreRangeChecked() throws IOException { + mockAll(200, "{}"); + int[][] bad = { + {4, 10, 5}, // reply_pct below 5 + {46, 10, 5}, // reply_pct above 45 + {10, 4, 5}, // important_pct below 5 + {10, 41, 5}, // important_pct above 40 + {10, 10, 71}, // click_pct above 70 + {10, 10, 18}, // click_pct above important_pct x 1.75 + }; + for (int[] values : bad) { + warmup.createCustomEngagement("a@b.com", values[0], values[1], values[2], + failOnSuccess, collect); + } + assertTrue(recorded.isEmpty()); + + warmup.createCustomEngagement("a@b.com", 20, 20, 35, succeed, this::failOnError); + JsonObject body = only().json(); + assertEquals(20, body.get("reply_pct").getAsInt()); + assertEquals(35, body.get("click_pct").getAsInt()); + } + + @Test + public void deleteCustomEngagementOnlyFallsBackToAStandardRule() throws IOException { + mockAll(200, "{}"); + warmup.deleteCustomEngagement("a@b.com", 0, failOnSuccess, collect); + assertTrue(errors.toString().contains("engagement_rule_id"), errors.toString()); + assertTrue(recorded.isEmpty()); + + warmup.deleteCustomEngagement("a@b.com", 3, succeed, this::failOnError); + assertEquals(3, only().json().get("engagement_rule_id").getAsInt()); + } + + @Test + public void updateSendingAccountChecksItsRanges() throws IOException { + mockAll(200, "{}"); + warmup.updateSendingAccount("a@b.com", 5, 4, true, failOnSuccess, collect); + warmup.updateSendingAccount("a@b.com", 1, 9, true, failOnSuccess, collect); + assertTrue(recorded.isEmpty()); + + warmup.updateSendingAccount("a@b.com", 1, 5, false, succeed, this::failOnError); + JsonObject body = only().json(); + assertEquals(1, body.get("team_id").getAsInt()); + assertEquals(5, body.get("status_id").getAsInt()); + assertFalse(body.get("auto_enable").getAsBoolean()); + } + + @Test + public void addSendingAccountChecksTheOnboardingModel() throws IOException { + mockAll(200, "{}"); + warmup.addSendingAccount("a@b.com", "managed", null, failOnSuccess, collect); + assertTrue(errors.toString().contains("self_managed"), errors.toString()); + + // self_managed needs its engagement rule and takes no ZB-managed fields. + warmup.addSendingAccount("a@b.com", "self_managed", null, failOnSuccess, collect); + warmup.addSendingAccount("a@b.com", "self_managed", + new ZeroBounceWarmup.AddSendingAccountOptions() + .setEngagementRuleId(0) + .setEmailContent(new ZeroBounceWarmup.EmailContent()), + failOnSuccess, collect); + assertTrue(recorded.isEmpty()); + + warmup.addSendingAccount("a@b.com", "self_managed", + new ZeroBounceWarmup.AddSendingAccountOptions() + .setEngagementRuleId(2) + .setIgnoreConflict(true), + succeed, this::failOnError); + JsonObject body = only().json(); + assertEquals("self_managed", body.get("onboarding_model").getAsString()); + assertEquals(2, body.get("engagement_rule_id").getAsInt()); + assertTrue(body.get("ignore_conflict").getAsBoolean()); + } + + @Test + public void addSendingAccountChecksTheZbManagedPayload() throws IOException { + mockAll(200, "{}"); + warmup.addSendingAccount("a@b.com", "zb_managed", + new ZeroBounceWarmup.AddSendingAccountOptions() + .setEmailContent(new ZeroBounceWarmup.EmailContent() + .setTypesOfEmails(List.of("promotional"))), + failOnSuccess, collect); + warmup.addSendingAccount("a@b.com", "zb_managed", + new ZeroBounceWarmup.AddSendingAccountOptions() + .setManagedSending(new ZeroBounceWarmup.ManagedSending() + .setTimezoneOffset(15)), + failOnSuccess, collect); + warmup.addSendingAccount("a@b.com", "zb_managed", + new ZeroBounceWarmup.AddSendingAccountOptions() + .setManagedSending(new ZeroBounceWarmup.ManagedSending() + .setWarmupStartDate("02/01/2026")), + failOnSuccess, collect); + assertTrue(recorded.isEmpty()); + + warmup.addSendingAccount("a@b.com", "zb_managed", + new ZeroBounceWarmup.AddSendingAccountOptions() + .setEmailContent(new ZeroBounceWarmup.EmailContent() + .setTypesOfEmails(List.of("newsletter", "marketing"))) + .setManagedSending(new ZeroBounceWarmup.ManagedSending() + .setTimezoneOffset(-5) + .setWarmupStartDate("2026-03-04")), + succeed, this::failOnError); + JsonObject body = only().json(); + assertEquals("zb_managed", body.get("onboarding_model").getAsString()); + assertEquals("marketing", + body.getAsJsonObject("email_content").getAsJsonArray("types_of_emails") + .get(1).getAsString()); + assertEquals("2026-03-04", + body.getAsJsonObject("managed_sending").get("warmup_start_date").getAsString()); + } + + @Test + public void listFromAddressesChecksItsSearchAndSendsPagingAsStrings() throws IOException { + mockAll(200, "{}"); + warmup.listFromAddresses("bad search!", null, null, failOnSuccess, collect); + warmup.listFromAddresses(null, -1, null, failOnSuccess, collect); + assertTrue(recorded.isEmpty()); + + warmup.listFromAddresses("a.b-c_d@e+f", 0, 50, succeed, this::failOnError); + Map query = only().query(); + assertEquals("a.b-c_d@e+f", query.get("search_from")); + assertEquals("0", query.get("offset")); + assertEquals("50", query.get("limit")); + } + + @Test + public void statisticsPerDayRejectsABackwardsRangeAndBadDates() throws IOException { + mockAll(200, "{}"); + warmup.getStatisticsPerDay("a@b.com", "2026-02-02", "2026-01-01", failOnSuccess, collect); + warmup.getStatisticsPerDay("a@b.com", "01/02/2026", "2026-01-01", failOnSuccess, collect); + assertTrue(recorded.isEmpty()); + } + + @Test + public void checkDomainDmarcRequiresAnEmailAddressNotADomain() throws IOException { + mockAll(200, "{}"); + // LIVE: the API answers a bare domain with "Please provide a valid + // email address", although the docs describe a domain. + warmup.checkDomainDmarc("example.com", null, failOnSuccess, collect); + assertTrue(errors.toString().contains("email address"), errors.toString()); + assertTrue(recorded.isEmpty()); + + warmup.checkDomainDmarc("sender@example.com", "zb", succeed, this::failOnError); + assertEquals("zb", only().query().get("dkim_selector")); + } + + @Test + public void customEngagementPathIdsCannotTraverse() throws IOException { + mockAll(200, "{}"); + warmup.getCustomEngagement(".", failOnSuccess, collect); + warmup.getCustomEngagement("..", failOnSuccess, collect); + assertTrue(recorded.isEmpty()); + } + + @Test + public void theSmtpTestChecksItsPort() throws IOException { + mockAll(200, "{}"); + for (int bad : new int[]{0, 65536}) { + errors.clear(); + warmup.testSmtpConfiguration("a@b.com", "u", "p", "s", bad, failOnSuccess, collect); + assertTrue(errors.toString().contains("port"), errors.toString()); + } + assertTrue(recorded.isEmpty()); + } + + // -- responses ----------------------------------------------------------- + + @Test + public void anEmptySuccessBecomesNull() throws IOException { + mockAll(204, ""); + List seen = new ArrayList<>(); + warmup.deleteSendingAccount("a@b.com", seen::add, this::failOnError); + assertEquals(1, seen.size()); + assertNull(seen.get(0)); + + recorded.clear(); + mockAll(200, ""); + seen.clear(); + warmup.getSeedCount(seen::add, this::failOnError); + assertEquals(1, seen.size()); + assertNull(seen.get(0)); + } + + @Test + public void aListPayloadComesBackUnderData() throws IOException { + mockAll(200, "[{\"seed\":\"a@b.com\"},{\"seed\":\"c@d.com\"}]"); + List seen = new ArrayList<>(); + warmup.getSeeds(seen::add, this::failOnError); + assertEquals(2, seen.get(0).getAsJsonArray("data").size()); + } + + @Test + public void csvDownloadsComeBackAsText() throws IOException { + mockAll(200, "seed,provider\na@b.com,gmail\n", "text/csv"); + List seen = new ArrayList<>(); + warmup.downloadSeeds(seen::add, this::failOnError); + assertEquals("seed,provider" + "a@b.com,gmail", seen.get(0)); + + recorded.clear(); + mockAll(200, "day,sent", "text/csv"); + warmup.downloadStatistics("a@b.com", succeedText, this::failOnError); + assertEquals("a@b.com", only().query().get("c_from")); + } + + @Test + public void aJsonErrorInPlaceOfACsvIsAnError() throws IOException { + mockAll(200, "{\"message\":\"No statistics for this address.\"}"); + warmup.downloadSeeds(r -> fail("success"), collect); + assertTrue(errors.toString().contains("No statistics for this address."), errors.toString()); + } + + @Test + public void theHttpStatusIsOnTheError() throws IOException { + mockAll(429, "{\"message\":\"Custom rule was updated less than 7 days ago.\"}"); + warmup.updateCustomEngagement("a@b.com", 10, 10, 5, failOnSuccess, collect); + assertEquals(429, lastStatus); + assertTrue(errors.toString().contains("Custom rule was updated"), errors.toString()); + } + + @Test + public void aValidationErrorListBecomesOneShortMessage() throws IOException { + mockAll(422, "{\"detail\":[{\"loc\":[\"body\",\"reply_pct\"],\"msg\":\"ensure this value is <= 45\"}]}"); + warmup.createCustomEngagement("a@b.com", 10, 10, 5, failOnSuccess, collect); + assertTrue(errors.toString().contains("reply_pct: ensure this value is <= 45"), + errors.toString()); + } + + @Test + public void aTopLevelStringListBecomesOneShortMessage() throws IOException { + mockAll(400, "[\"Sending account already exists.\"]"); + warmup.addSendingAccount("a@b.com", "self_managed", + new ZeroBounceWarmup.AddSendingAccountOptions().setEngagementRuleId(0), + failOnSuccess, collect); + assertTrue(errors.toString().contains("Sending account already exists."), errors.toString()); + } + + @Test + public void anHtmlErrorPageIsNotPastedIntoTheMessage() throws IOException { + mockAll(403, "Access denied" + + "

blocked

".repeat(2000) + "", "text/html"); + warmup.getSeeds(failOnSuccess, collect); + assertTrue(errors.toString().contains("HTML error page"), errors.toString()); + assertEquals(403, lastStatus); + } + + @Test + public void aRedirectIsRefusedRatherThanFollowed() throws IOException { + mockAll(302, ""); + warmup.getSeeds(failOnSuccess, collect); + assertTrue(errors.toString().contains("redirect"), errors.toString()); + assertEquals(302, lastStatus); + } + + @Test + public void theKeyNeverAppearsInAnError() throws IOException { + mockAll(401, "{\"message\":\"Invalid key " + API_KEY + "\"}"); + warmup.getSeeds(failOnSuccess, collect); + assertFalse(errors.toString().contains(API_KEY), errors.toString()); + assertTrue(errors.toString().contains("***"), errors.toString()); + } + + @Test + public void smtpCredentialsNeverAppearInAnError() throws IOException { + mockAll(500, "{\"message\":\"login failed for user bob with s3cret\"}"); + warmup.testSmtpConfiguration("a@b.com", "bob", "s3cret", "smtp.example.com", 587, + failOnSuccess, collect); + assertFalse(errors.toString().contains("s3cret"), errors.toString()); + assertFalse(errors.toString().contains("bob"), errors.toString()); + // LIVE: a failed SMTP login comes back as a 500, not a 4xx. + assertEquals(500, lastStatus); + } + + @Test + public void aMalformedBodyDoesNotLeakAParserDump() throws IOException { + mockAll(200, "<<>>" + "x".repeat(5000)); + warmup.getSeeds(failOnSuccess, collect); + assertTrue(errors.toString().contains("not valid JSON"), errors.toString()); + } +} From c7ae5130faa0d8da17c4db830749b7aef336fc0e Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:16:18 +0400 Subject: [PATCH 5/7] test: add an opt-in live API suite, and two bugs it found live/LiveApi.java calls every SDK method against the real API. It lives outside the Maven module's source root, so `mvn test` and CI never compile or run it; it is compiled and run by hand with keys in the environment. Free calls run by default: sandbox addresses, reads, and requests expected to fail that still prove the API accepts the payload. The free tier asserts the credit balance did not move. ZEROBOUNCE_LIVE_SPEND=1 adds the billable calls and a bulk round trip that deletes its own job; ZEROBOUNCE_LIVE_CHANGES=1 adds account changes, each undone in cleanup even when the test fails. Nothing the API cannot undo is ever called. Two things only the live run could catch: - listFilters threw. GET /filters/list answers with a JSON array, not an object, so it now hands back a JsonElement. - The Warmup client threw on /seed-count, which answers with a bare number. Any payload that is not an object now comes back wrapped under "data", and a body that is not JSON at all is rejected before Gson's lenient parser can read it as a string. Co-Authored-By: Claude Opus 5 --- live/LiveApi.java | 578 ++++++++++++++++++ .../java/com/zerobounce/ZeroBounceSDK.java | 26 +- .../java/com/zerobounce/ZeroBounceWarmup.java | 37 +- 3 files changed, 631 insertions(+), 10 deletions(-) create mode 100644 live/LiveApi.java diff --git a/live/LiveApi.java b/live/LiveApi.java new file mode 100644 index 0000000..91fd646 --- /dev/null +++ b/live/LiveApi.java @@ -0,0 +1,578 @@ +package com.zerobounce.live; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import com.zerobounce.ErrorResponse; +import com.zerobounce.ZBBulkFileJobOptions; +import com.zerobounce.ZBValidateBatchData; +import com.zerobounce.ZBValidateOptions; +import com.zerobounce.ZeroBounceSDK; +import com.zerobounce.ZeroBounceWarmup; + +import java.io.File; +import java.io.FileWriter; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +/** + * Opt-in live API suite: calls every SDK method against the real API to check + * payloads and response shapes. + *

+ * It lives outside the Maven module's source root, so {@code mvn test} and CI + * never compile or run it. Compile and run it by hand: + * + *

+ * mvn -f zero-bounce-sdk/pom.xml dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
+ * CP="zero-bounce-sdk/target/classes:$(cat /tmp/cp.txt)"
+ * javac -cp "$CP" -d live/out live/LiveApi.java
+ * export ZEROBOUNCE_API_KEY=...          # validation API (skipped without it)
+ * export ZEROBOUNCE_WARMUP_API_KEY=...   # Warmup API (skipped without it)
+ * java -cp "$CP:live/out" com.zerobounce.live.LiveApi
+ * 
+ *

+ * By default only free calls run: sandbox addresses (no credits), reads, and + * requests expected to fail (unknown file IDs, S3/SFTP without an integration) + * that still prove the API accepts the payload. The free tier checks that the + * credit balance did not change. Two flags opt in to more: + *

    + *
  • {@code ZEROBOUNCE_LIVE_SPEND=1} spends credits: scoring, activity, + * email finder, and a bulk round trip whose job is deleted again.
  • + *
  • {@code ZEROBOUNCE_LIVE_CHANGES=1} reversible account changes, each + * undone in cleanup even when a test fails: a test filter, and a + * self-managed Warmup sending account.
  • + *
+ *

+ * Never called, because the API cannot undo them: creating an Inbox Placement + * test (it cannot be deleted while running), setting the Inbox Placement + * webhook (it cannot be cleared), and ZB-managed Warmup onboarding (it starts + * mailbox provisioning). + *

+ * No key is ever written to a file or printed. Keys come from the environment. + */ +public final class LiveApi { + + /** + * Well-formed but matching no file: a malformed ID can be + * Cloudflare-blocked instead of answered. + */ + private static final String UNKNOWN_ID = "0d1b7a4e-1111-2222-3333-444455556666"; + private static final String CALLBACK = "https://example.com/zerobounce-sdk-live-test"; + private static final String SANDBOX = "valid@example.com"; + + private static int passed; + private static int failed; + private static int skipped; + private static final List failures = new ArrayList<>(); + + private static String env(String name) { + String value = System.getenv(name); + return value == null ? "" : value.trim(); + } + + private static String apiKey() { + return env("ZEROBOUNCE_API_KEY"); + } + + private static String warmupKey() { + return env("ZEROBOUNCE_WARMUP_API_KEY"); + } + + private static boolean flag(String name) { + return "1".equals(env(name)); + } + + private static String redact(String text) { + String value = text == null ? "" : text; + for (String secret : Arrays.asList(apiKey(), warmupKey())) { + if (!secret.isEmpty()) { + value = value.replace(secret, "***"); + } + } + return value; + } + + /** A check either passes, or records why it did not. */ + private static void check(String name, Supplier body) { + String problem; + try { + problem = body.get(); + } catch (RuntimeException e) { + problem = redact(String.valueOf(e.getMessage())); + } + if (problem == null || problem.isEmpty()) { + passed++; + System.out.println(" + " + name); + } else { + failed++; + failures.add(name + ": " + redact(problem)); + System.out.println(" X " + name + "\n " + redact(problem)); + } + } + + private static void skip(String name, String reason) { + skipped++; + System.out.println(" - SKIP " + name + " (" + reason + ")"); + } + + /** Runs a call and reports what it produced: null on success. */ + private static String run(BiConsumer call) { + AtomicReference problem = new AtomicReference<>("no response"); + call.accept(() -> problem.set(null), + error -> problem.set(redact(error.getErrors().toString()))); + return problem.get(); + } + + /** Runs a call that must fail, and hands back the error's text. */ + private static String expectFailure( + String what, BiConsumer call) { + AtomicReference message = new AtomicReference<>(null); + call.accept(() -> message.set(null), + error -> message.set(redact(error.getErrors().toString()))); + return message.get() == null ? what + " unexpectedly succeeded" : null; + } + + private static ZeroBounceSDK sdk() { + ZeroBounceSDK zb = ZeroBounceSDK.getInstance(); + zb.initialize(apiKey()); + return zb; + } + + public static void main(String[] args) { + String key = apiKey(); + String warmup = warmupKey(); + boolean spend = flag("ZEROBOUNCE_LIVE_SPEND"); + boolean changes = flag("ZEROBOUNCE_LIVE_CHANGES"); + + System.out.println("ZeroBounce live API suite"); + System.out.println(" validation key: " + (key.isEmpty() ? "MISSING" : "set")); + System.out.println(" warmup key: " + (warmup.isEmpty() ? "MISSING" : "set")); + System.out.println(" flags: SPEND=" + (spend ? 1 : 0) + + " CHANGES=" + (changes ? 1 : 0) + "\n"); + + if (!key.isEmpty()) { + validationFree(); + if (spend) { + validationSpend(); + } + if (changes) { + validationChanges(); + } + } + if (!warmup.isEmpty()) { + warmupFree(); + if (changes) { + warmupChanges(); + } + } + + System.out.println("\n" + passed + " passed, " + failed + " failed, " + skipped + " skipped"); + if (!failures.isEmpty()) { + System.out.println("\nFailures:"); + for (String line : failures) { + System.out.println(" - " + line); + } + } + System.exit(failed == 0 ? 0 : 1); + } + + // -- validation API, free ------------------------------------------------- + + private static void validationFree() { + System.out.println("Validation API (free calls)"); + ZeroBounceSDK zb = sdk(); + AtomicReference before = new AtomicReference<>(null); + + check("getCredits returns a balance", () -> run((ok, err) -> + zb.getCredits(response -> { + before.set(response.getCredits()); + ok.run(); + }, err))); + + check("getApiUsage parses the documented date formats", () -> run((ok, err) -> { + long day = 24L * 3600 * 1000; + zb.getApiUsage(new Date(System.currentTimeMillis() - 7 * day), new Date(), + response -> ok.run(), err); + })); + + check("validate on a sandbox address spends nothing", () -> { + AtomicReference problem = new AtomicReference<>("no response"); + zb.validate(SANDBOX, null, response -> problem.set( + SANDBOX.equals(response.getAddress()) ? null : "address did not echo"), + error -> problem.set(redact(error.getErrors().toString()))); + return problem.get(); + }); + + check("validate works over POST as well as GET", () -> run((ok, err) -> { + // No billable add-on here: activity_data, verify_plus and + // domain_info are charged even on a sandbox address, so they live + // in the SPEND tier. + ZBValidateOptions options = new ZBValidateOptions().setUsePost(true).setTimeout(30); + zb.validate(SANDBOX, null, options, response -> ok.run(), err); + })); + + check("LIVE: validateBatch accepts timeout as a string", () -> { + // Sent as a JSON number the API rejects the whole batch with the + // account-level "Invalid API Key or your account ran out of credits". + AtomicReference problem = new AtomicReference<>("no response"); + zb.validateBatch(List.of(new ZBValidateBatchData(SANDBOX, null)), + new ZBValidateOptions().setTimeout(30), + response -> { + problem.set(null); + if (response.getErrors() != null) { + response.getErrors().forEach(error -> { + Object address = error.get("email_address"); + if ("all".equalsIgnoreCase(String.valueOf(address))) { + problem.set("account-level error: " + error.get("error")); + } + }); + } + }, + error -> problem.set(redact(error.getErrors().toString()))); + return problem.get(); + }); + + check("an unknown file id is a short, clean error", () -> + expectFailure("fileStatus", (ok, err) -> + zb.fileStatus(UNKNOWN_ID, response -> ok.run(), err))); + + check("listS3Files answers (empty without an S3 integration)", () -> { + AtomicReference problem = new AtomicReference<>(null); + zb.listS3Files(null, null, files -> { + }, error -> { + // No S3 integration configured is an expected, documented failure. + if (error.getErrors().toString().length() >= 500) { + problem.set("message too long"); + } + }); + return problem.get(); + }); + + check("LIVE: S3/SFTP jobs reject a payload without return_url before sending", () -> { + AtomicReference message = new AtomicReference<>(null); + zb.emailFinderS3File(new ZBBulkFileJobOptions().setFileName("f.csv").setHasHeaderRow(true), + response -> message.set("unexpectedly succeeded"), + error -> message.set(error.getErrors().toString().contains("return_url") + ? null : "unexpected message: " + redact(error.getErrors().toString()))); + return message.get(); + }); + + check("LIVE: a complete S3 job payload is accepted by the API", () -> { + // Without an S3 integration this fails on the integration, not the payload. + AtomicReference problem = new AtomicReference<>(null); + zb.emailFinderS3File(new ZBBulkFileJobOptions() + .setFileName("f.csv").setHasHeaderRow(true).setReturnUrl(CALLBACK), + response -> { + }, + error -> { + String message = redact(error.getErrors().toString()); + if (message.contains("return URL is not permitted") + || message.contains("This field is required")) { + problem.set("payload rejected: " + message); + } + }); + return problem.get(); + }); + + check("filters list", () -> run((ok, err) -> + zb.listFilters(false, response -> ok.run(), err))); + + check("LIVE: the Inbox Placement Tester accepts a Bearer key", () -> run((ok, err) -> + zb.getInboxPlacementProviders(response -> ok.run(), err))); + + check("inbox placement test types", () -> run((ok, err) -> + zb.getInboxPlacementTestTypes(response -> ok.run(), err))); + + check("LIVE: inbox placement tests are listed from /tests", () -> { + // The listing is GET /tests (plural); /test is the single-test path + // and answers Method "GET" not allowed. + String problem = run((ok, err) -> zb.listInboxPlacementTests( + new ZeroBounceSDK.ListInboxPlacementTestsOptions().setPage(1).setPageSize(10), + response -> ok.run(), err)); + if (problem != null) { + return problem; + } + return run((ok, err) -> zb.getInboxPlacementWebhook(response -> ok.run(), err)); + }); + + check("list evaluator result for an unknown id fails cleanly", () -> + expectFailure("getListEvaluatorResult", (ok, err) -> + zb.getListEvaluatorResult(UNKNOWN_ID, response -> ok.run(), err))); + + check("LIVE: sendEvaluatedFile posts the file id as a form field", () -> { + AtomicReference message = new AtomicReference<>("unexpectedly succeeded"); + zb.sendEvaluatedFile(UNKNOWN_ID, response -> message.set("unexpectedly succeeded"), + error -> { + String text = redact(error.getErrors().toString()); + message.set(text.contains("not allowed") ? "wrong method or path: " + text : null); + }); + return message.get(); + }); + + check("the free calls did not change the credit balance", () -> { + AtomicReference problem = new AtomicReference<>("no response"); + sdk().getCredits(response -> problem.set( + String.valueOf(before.get()).equals(response.getCredits()) + ? null + : "credits went from " + before.get() + " to " + response.getCredits()), + error -> problem.set(redact(error.getErrors().toString()))); + return problem.get(); + }); + } + + // -- validation API, spends credits --------------------------------------- + + private static void validationSpend() { + System.out.println("\nValidation API (ZEROBOUNCE_LIVE_SPEND=1)"); + ZeroBounceSDK zb = sdk(); + + check("validate accepts every documented option", () -> run((ok, err) -> { + // LIVE: activity_data, verify_plus and domain_info are billable + // add-ons and are charged even for a sandbox address. + ZBValidateOptions options = new ZBValidateOptions() + .setTimeout(30) + .setActivityData(true) + .setVerifyPlus(true) + .setDomainInfo(true) + .setGreylistProcessing(true) + .setCallbackUrl(CALLBACK); + zb.validate(SANDBOX, null, options, response -> ok.run(), err); + })); + + check("scoreEmail", () -> run((ok, err) -> + zb.scoreEmail(SANDBOX, response -> ok.run(), err))); + check("scoreEmail over POST", () -> run((ok, err) -> + zb.scoreEmail(SANDBOX, true, response -> ok.run(), err))); + check("getActivityData", () -> run((ok, err) -> + zb.getActivityData(SANDBOX, response -> ok.run(), err))); + check("email finder", () -> run((ok, err) -> + zb.findEmail("John", "example.com", null, null, "Doe", response -> ok.run(), err))); + check("domain search", () -> run((ok, err) -> + zb.findDomain("example.com", null, response -> ok.run(), err))); + + AtomicReference fileId = new AtomicReference<>(null); + check("bulk validation round trip: upload, status, delete", () -> { + File file = new File("zb-sdk-live.csv"); + try (FileWriter out = new FileWriter(file)) { + out.write("email\nvalid@example.com\ninvalid@example.com\n"); + } catch (Exception e) { + return "could not write the test file: " + e.getMessage(); + } + AtomicReference problem = new AtomicReference<>("no response"); + try { + zb.sendFile(file, 1, null, response -> { + if (response.getFileId() == null || response.getFileId().isEmpty()) { + problem.set("no file_id in the upload response"); + return; + } + fileId.set(response.getFileId()); + problem.set(null); + }, error -> problem.set(redact(error.getErrors().toString()))); + } catch (com.zerobounce.ZBException e) { + problem.set(redact(e.getMessage())); + } + file.delete(); + if (problem.get() != null) { + return problem.get(); + } + problem.set("no status response"); + zb.fileStatus(fileId.get(), status -> problem.set( + fileId.get().equals(status.getFileId()) ? null : "status did not echo the file id"), + error -> problem.set(redact(error.getErrors().toString()))); + return problem.get(); + }); + + if (fileId.get() == null) { + skip("the uploaded validation job is deleted again", "upload did not produce a file id"); + } else { + check("the uploaded validation job is deleted again", () -> { + // LIVE: a just-accepted job answers "File cannot be found" for + // a short while, so the delete is retried rather than leaving + // the job behind. + AtomicReference last = new AtomicReference<>(""); + for (int attempt = 0; attempt < 12; attempt++) { + AtomicReference done = new AtomicReference<>(false); + zb.deleteFile(fileId.get(), response -> done.set(true), + error -> last.set(redact(error.getErrors().toString()))); + if (done.get()) { + return null; + } + try { + Thread.sleep(10_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + return "could not delete " + fileId.get() + " after retrying: " + last.get(); + }); + } + } + + // -- validation API, reversible account changes --------------------------- + + private static void validationChanges() { + System.out.println("\nValidation API (ZEROBOUNCE_LIVE_CHANGES=1)"); + ZeroBounceSDK zb = sdk(); + String value = "zb-sdk-live-" + System.currentTimeMillis() + ".example.com"; + AtomicReference added = new AtomicReference<>(false); + + check("addFilter creates a reversible allow rule", () -> run((ok, err) -> + zb.addFilter("allow", "domain", value, response -> { + added.set(true); + ok.run(); + }, err))); + + if (!added.get()) { + skip("the test filter is removed again", "filter was not created"); + return; + } + check("the test filter is removed again", () -> run((ok, err) -> + zb.deleteFilter("allow", "domain", value, response -> ok.run(), err))); + } + + // -- Warmup API ----------------------------------------------------------- + + private static ZeroBounceWarmup warmupClient() { + ZeroBounceWarmup zb = ZeroBounceWarmup.getInstance(); + zb.initialize(warmupKey()); + return zb; + } + + private static void warmupFree() { + System.out.println("\nWarmup API (reads)"); + ZeroBounceWarmup zb = warmupClient(); + + check("listSendingAccounts", () -> run((ok, err) -> + zb.listSendingAccounts(response -> ok.run(), err))); + check("listFromAddresses", () -> run((ok, err) -> + zb.listFromAddresses(null, null, null, response -> ok.run(), err))); + check("getEngagementRules", () -> run((ok, err) -> + zb.getEngagementRules(response -> ok.run(), err))); + check("getSeeds", () -> run((ok, err) -> zb.getSeeds(response -> ok.run(), err))); + check("getSeedCount", () -> run((ok, err) -> zb.getSeedCount(response -> ok.run(), err))); + check("getProgressToday", () -> run((ok, err) -> + zb.getProgressToday(response -> ok.run(), err))); + check("getSentToday", () -> run((ok, err) -> zb.getSentToday(response -> ok.run(), err))); + check("getEmailsPerDay", () -> run((ok, err) -> + zb.getEmailsPerDay(response -> ok.run(), err))); + + check("downloadSeeds returns CSV text", () -> { + AtomicReference problem = new AtomicReference<>("no response"); + zb.downloadSeeds(csv -> problem.set( + csv != null && !csv.trim().startsWith("{") ? null : "got JSON instead of CSV"), + error -> problem.set(redact(error.getErrors().toString()))); + return problem.get(); + }); + + check("LIVE: checkDomainDmarc rejects a bare domain before sending", () -> { + AtomicReference message = new AtomicReference<>("unexpectedly succeeded"); + zb.checkDomainDmarc("example.com", null, + response -> message.set("unexpectedly succeeded"), + error -> { + String text = error.getErrors().toString(); + message.set(text.contains("email address") ? null : "unexpected message: " + text); + }); + return message.get(); + }); + + check("LIVE: checkDomainDmarc accepts an email address", () -> run((ok, err) -> + zb.checkDomainDmarc("sender@example.com", null, response -> ok.run(), err))); + + // The statistics endpoints need a from-address this team actually has; + // the API answers "Sending account ... was not found" for anything else. + AtomicReference known = new AtomicReference<>(null); + zb.listSendingAccounts(response -> { + if (response == null) { + return; + } + JsonElement list = response.has("data") ? response.get("data") : response; + if (!list.isJsonArray() || list.getAsJsonArray().isEmpty()) { + return; + } + JsonObject first = list.getAsJsonArray().get(0).getAsJsonObject(); + for (String field : new String[]{"from_address", "c_from", "fromAddress"}) { + if (first.has(field) && first.get(field).isJsonPrimitive()) { + known.set(first.get(field).getAsString()); + return; + } + } + }, error -> { + }); + + if (known.get() == null) { + for (String name : new String[]{ + "statistics for a registered from-address", + "statistics per day over a short range", + "detailed statistics and the CSV download for that address", + "from-address statistics"}) { + skip(name, "this team has no sending account"); + } + } else { + String address = known.get(); + check("statistics for a registered from-address", () -> run((ok, err) -> + zb.getStatistics(address, response -> ok.run(), err))); + check("statistics per day over a short range", () -> run((ok, err) -> + zb.getStatisticsPerDay(address, + LocalDate.now().minusDays(6).toString(), LocalDate.now().toString(), + response -> ok.run(), err))); + check("detailed statistics and the CSV download for that address", () -> { + String problem = run((ok, err) -> + zb.getStatisticsDetailed(address, response -> ok.run(), err)); + if (problem != null) { + return problem; + } + return run((ok, err) -> zb.downloadStatistics(address, csv -> ok.run(), err)); + }); + check("from-address statistics", () -> run((ok, err) -> + zb.getFromAddressStatistics(address, response -> ok.run(), err))); + } + + check("an unknown Warmup resource fails with a status code", () -> { + AtomicReference problem = new AtomicReference<>(null); + zb.getCustomEngagement("no-such-address@example.com", response -> { + }, error -> { + if (error.getStatusCode() == 0) { + problem.set("no status code on the error"); + } else if (error.getErrors().toString().contains(warmupKey())) { + problem.set("the key leaked into the message"); + } + }); + return problem.get(); + }); + } + + private static void warmupChanges() { + System.out.println("\nWarmup API (ZEROBOUNCE_LIVE_CHANGES=1)"); + ZeroBounceWarmup zb = warmupClient(); + String address = "zb-sdk-live-" + System.currentTimeMillis() + "@example.com"; + AtomicReference added = new AtomicReference<>(false); + + check("a self-managed sending account can be added", () -> run((ok, err) -> + zb.addSendingAccount(address, "self_managed", + new ZeroBounceWarmup.AddSendingAccountOptions() + .setEngagementRuleId(0) + .setIgnoreConflict(true), + response -> { + added.set(true); + ok.run(); + }, err))); + + if (!added.get()) { + skip("the test sending account is removed again", "account was not created"); + return; + } + check("the test sending account is removed again", () -> run((ok, err) -> + zb.deleteSendingAccount(address, response -> ok.run(), err))); + } + + private LiveApi() { + } +} 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 5a750b5..f9b3874 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceSDK.java @@ -1310,10 +1310,14 @@ public void deleteFilter( */ public void listFilters( boolean usePost, - @NotNull OnSuccessCallback successCallback, + @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback ) { - filterRequest("/filters/list", null, null, null, usePost, successCallback, errorCallback); + // LIVE: /filters/list answers with a JSON *array*, not an object, so + // this takes a JsonElement. Read as an object it threw. + filterRequest("/filters/list", null, null, null, usePost, + new TypeToken() { + }, successCallback, errorCallback); } private void filterRequest( @@ -1324,6 +1328,21 @@ private void filterRequest( boolean usePost, @NotNull OnSuccessCallback successCallback, @NotNull OnErrorCallback errorCallback + ) { + filterRequest(path, rule, target, value, usePost, + new TypeToken() { + }, successCallback, errorCallback); + } + + private void filterRequest( + @NotNull String path, + @Nullable String rule, + @Nullable String target, + @Nullable String value, + boolean usePost, + @NotNull TypeToken typeToken, + @NotNull OnSuccessCallback successCallback, + @NotNull OnErrorCallback errorCallback ) { if (invalidApiKey(errorCallback)) return; @@ -1339,8 +1358,7 @@ private void filterRequest( usePost ? null : parameters, usePost ? parameters : null, null, - new TypeToken() { - }, + typeToken, successCallback, errorCallback ); diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceWarmup.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceWarmup.java index d7f67c2..1011dc6 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceWarmup.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZeroBounceWarmup.java @@ -238,16 +238,25 @@ private void json( successCallback.onSuccess(null); return; } + if (!looksLikeJson(result.body)) { + // Gson parses leniently, so junk would come back as a string + // instead of raising: reject it before it reaches the parser. + errorCallback.onError(ErrorResponse.parseError( + redact(messageFromBody(result.body, result.status), secrets), result.status)); + return; + } try { - // A list payload is wrapped so one return type covers every endpoint. + // LIVE: not every endpoint answers with an object. /seeds is a JSON + // array and /seed-count is a bare number, so anything that is not + // an object is wrapped under "data" and one return type covers all. com.google.gson.JsonElement parsed = JsonParser.parseString(result.body); - if (parsed.isJsonArray()) { - JsonObject wrapper = new JsonObject(); - wrapper.add("data", parsed); - successCallback.onSuccess(wrapper); + if (parsed.isJsonObject()) { + successCallback.onSuccess(parsed.getAsJsonObject()); return; } - successCallback.onSuccess(parsed.getAsJsonObject()); + JsonObject wrapper = new JsonObject(); + wrapper.add("data", parsed); + successCallback.onSuccess(wrapper); } catch (RuntimeException e) { errorCallback.onError(ErrorResponse.parseError( redact(messageFromBody(result.body, result.status), secrets), result.status)); @@ -273,6 +282,22 @@ private void csv( successCallback.onSuccess(result.body); } + /** + * Whether a body could be JSON at all. Gson's parser is lenient and would + * read bare junk as a string, so the shape is checked first. + */ + private static boolean looksLikeJson(@Nullable String body) { + String trimmed = body == null ? "" : body.trim(); + if (trimmed.isEmpty()) { + return false; + } + char first = trimmed.charAt(0); + if (first == '{' || first == '[' || first == '"' || first == '-' || Character.isDigit(first)) { + return true; + } + return trimmed.equals("true") || trimmed.equals("false") || trimmed.equals("null"); + } + /** Turn an error body into a short message. Never raw HTML or a parser dump. */ private String messageFromBody(@Nullable String body, int status) { String trimmed = body == null ? "" : body.trim(); From 6dfbece7feaf97f6918d987b1668ce1e10ab0da2 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:29:54 +0400 Subject: [PATCH 6/7] docs: cover the Warmup API, the live suite and the live findings README gains a Warmup API section and the opt-in live suite with its two flags, and its S3/SFTP examples now pass the file_name and return_url the API requires. AGENTS.md records where the live API and the docs disagree, the rule that the API wins and the call site says so, and the release rule that the live suite is run by hand before every release. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 30 ++++++++++++++-- README.md | 102 +++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 113 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 310b185..2a94a20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,9 @@ - `zero-bounce-sdk/` is the published Maven module. `src/` at the repo root is only the sample app. - `ZeroBounceSDK.java` holds every request. `sendRequest` is the original GET/JSON-POST helper; `sendRequestWithMethod` is the one that can also do form-encoded POSTs and the Inbox Placement Tester's PUT and DELETE. - `ZBValidateOptions`, `ZBBulkFileJobOptions`: the option objects, each with a package-private `fill` that both builds the request and rejects an invalid combination before anything is sent. -- `OfflineApiTest.java`: the offline suite. A mocked `HttpURLConnection` answers every call and records the url, method and body, so the assertions are on what actually went on the wire. +- `ZeroBounceWarmup.java`: a separate client for the Warmup API, a different product with its own host and its own team-scoped key. Not part of `ZeroBounceSDK`. +- `OfflineApiTest.java`, `OfflineWarmupTest.java`: the offline suites. A mocked `HttpURLConnection` answers every call and records the url, method, headers and body, so the assertions are on what actually went on the wire. +- `live/LiveApi.java`: the opt-in live suite. It sits outside the Maven module's source root, so `mvn test` and CI never compile or run it. ## Build and test @@ -21,11 +23,33 @@ That build runs `mvn -q test` and fails the build if a test fails — but the co docker run --rm -u 0 -v "$PWD":/src -w /src/zero-bounce-sdk zerobounce-sdk-java:test mvn -B test ``` +The live suite is compiled and run by hand, never by Maven: + +```sh +mvn -f zero-bounce-sdk/pom.xml dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt +CP="zero-bounce-sdk/target/classes:$(cat /tmp/cp.txt)" +javac -cp "$CP" -d live/out live/LiveApi.java +export ZEROBOUNCE_API_KEY=... ZEROBOUNCE_WARMUP_API_KEY=... +java -cp "$CP:live/out" com.zerobounce.live.LiveApi # free calls only +ZEROBOUNCE_LIVE_SPEND=1 java -cp "$CP:live/out" com.zerobounce.live.LiveApi +ZEROBOUNCE_LIVE_CHANGES=1 java -cp "$CP:live/out" com.zerobounce.live.LiveApi +``` + +Keys come from the environment only. Never write one into a file, a commit, a log, a fixture or a message. + ## Rules - **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 and SFTP bulk endpoints live under https://www.zerobounce.net/docs/api-integrations, not the main API reference. - **Four base URLs.** `apiBaseUrl`, `bulkApiBaseUrl` (ends in `/v2`), `bulkApiRootUrl` (the S3/SFTP jobs, **no** `/v2`), and `listEvaluatorBaseUrl` / `inboxPlacementBaseUrl`. The last two are separate hosts rather than paths built from the bulk base, so `initialize(apiKey, apiBaseUrl)` alone leaves them on the live API — use the four-argument `initialize` in tests. -- **Two bulk shapes are easy to get wrong.** `validateSFTPFile`'s `file_name` is optional — leaving it unset uses the input bucket from the account's settings, and every other job requires it. `exportSFTPFile` sends `export_path`, not the `export_bucket` that `exportS3File` sends. +- **Where the live API and the docs disagree, follow the API and say so in a comment at the call site.** The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer`, not `api_key`; its listing is `GET /tests`, not `/test`; `send-evaluated-file` takes `file_id` as a form field, not a file upload; the S3 and SFTP jobs require `return_url` and `file_name`, `validateSFTPFile` included; `GET /filters/list` answers with a JSON *array*; the Warmup `/seed-count` answers with a bare number and `check-domain-dmarc` takes an email address, not a domain; and a failed SMTP login in `test-smtp-configuration` comes back as an HTTP 500. +- **`exportSFTPFile` sends `export_path`**, not the `export_bucket` that `exportS3File` sends. +- **`{"success": false}` on an HTTP 200 is a failure.** The API answers an unknown bulk file id that way. `apiSuccessIsFalse` turns it into an error on every request path. +- **A response is not always a JSON object.** Read into a `JsonElement` when it may be an array or a scalar, and remember Gson parses leniently: junk reads as a string instead of raising, so the shape is checked before parsing. - **`ErrorResponse.parseError` already handles `detail`.** It collects every value of the error JSON, so the Inbox Placement Tester's Django-style `{"detail": ...}` surfaces without a special case. A test pins that. - **The version lives in the parent pom's ``.** Both poms read it; bumping one alone breaks the release. -- **Offline tests only.** Never call the live API from a test. +- **Offline tests only, in the Maven module.** Never call the live API from a test under `src/test`. The live suite is the separate, opt-in `live/LiveApi.java`. +- **Errors carry the HTTP status.** `ErrorResponse.getStatusCode()` is set on every path, so callers can tell a Cloudflare 403 from an API 400/404 and a Warmup 429. A failure that never reached the API leaves it 0. + +## Releasing + +**Run the live suite by hand before every release.** The offline suite proves the SDK sends what we think it sends; only the live suite proves the API accepts it. Run it with real keys, including `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`, and do not release on a failure. diff --git a/README.md b/README.md index 9fefe80..87a6c04 100644 --- a/README.md +++ b/README.md @@ -592,24 +592,29 @@ ZeroBounceSDK zb = ZeroBounceSDK.getInstance(); // Amazon S3. The file name is the full path, as returned by listS3Files. zb.listS3Files(null, null, onSuccess, onError); // optional bucket, search -zb.validateS3File(new ZBBulkFileJobOptions() +// file_name and return_url are required on every one of these jobs. +ZBBulkFileJobOptions s3 = new ZBBulkFileJobOptions() .setFileName("lists/leads.csv") - .setHasHeaderRow(true), onSuccess, onError); -zb.scoreS3File(new ZBBulkFileJobOptions().setFileName("lists/leads.csv"), onSuccess, onError); -zb.emailFinderS3File(new ZBBulkFileJobOptions().setFileName("lists/leads.csv"), onSuccess, onError); -zb.domainSearchS3File(new ZBBulkFileJobOptions() - .setFileName("lists/leads.csv") - .setRemoveDuplicates(true) - .setReturnUrl("https://example.com/zerobounce-done"), onSuccess, onError); + .setHasHeaderRow(true) + .setReturnUrl("https://example.com/zerobounce-done"); + +zb.validateS3File(s3, onSuccess, onError); +zb.scoreS3File(s3, onSuccess, onError); +zb.emailFinderS3File(s3, onSuccess, onError); +zb.domainSearchS3File(s3, onSuccess, onError); zb.exportS3File("", "my-export-bucket", onSuccess, onError); // SFTP/FTP. The server, path and protocol come from your ZeroBounce settings. -// The file name may be left unset for validateSFTPFile alone, which then uses -// the input bucket from those settings. -zb.validateSFTPFile(new ZBBulkFileJobOptions().setHasHeaderRow(true), onSuccess, onError); -zb.scoreSFTPFile(new ZBBulkFileJobOptions().setFileName("my_file.txt"), onSuccess, onError); -zb.emailFinderSFTPFile(new ZBBulkFileJobOptions().setFileName("my_file.txt"), onSuccess, onError); -zb.domainSearchSFTPFile(new ZBBulkFileJobOptions().setFileName("my_file.txt"), onSuccess, onError); +// The docs list file_name as optional for validateSFTPFile, but the live API +// requires it there too. +ZBBulkFileJobOptions sftp = new ZBBulkFileJobOptions() + .setFileName("my_file.txt") + .setReturnUrl("https://example.com/zerobounce-done"); + +zb.validateSFTPFile(sftp, onSuccess, onError); +zb.scoreSFTPFile(sftp, onSuccess, onError); +zb.emailFinderSFTPFile(sftp, onSuccess, onError); +zb.domainSearchSFTPFile(sftp, onSuccess, onError); // Note the field: this one takes export_path, not export_bucket. // Pass null to use the export path from your settings. @@ -623,12 +628,16 @@ the file id is URL-encoded as a single path segment. ```java ZeroBounceSDK.getInstance().getListEvaluatorResult("", onSuccess, onError); + +// Submit a finished evaluation for validation. This takes the evaluator's +// file_id as a form field, not a file upload. +ZeroBounceSDK.getInstance().sendEvaluatedFile("", onSuccess, onError); ``` ## Inbox Placement Tester -Runs on its own host, takes the API key on the query string, and sends camelCase -JSON bodies. It spends the account's inbox-test credits. +Runs on its own host, authenticates with an `Authorization: Bearer` header, and +sends camelCase JSON bodies. It spends the account's inbox-test credits. ```java ZeroBounceSDK zb = ZeroBounceSDK.getInstance(); @@ -884,6 +893,67 @@ You should then go to the [Nexus Sonatype](https://s01.oss.sonatype.org/), login gpg --list-secret-keys ``` +## Warmup API + +The Warmup API is a separate product. It has its own host, its own team-scoped key, and its own client; your validation key will not work with it. + +```java +ZeroBounceWarmup warmup = ZeroBounceWarmup.getInstance(); +warmup.initialize(""); + +warmup.listSendingAccounts(onSuccess, onError); +warmup.getProgressToday(onSuccess, onError); +warmup.downloadSeeds(csv -> { /* CSV text, columns seed,provider */ }, onError); +``` + +Add a from-address you run yourself, with one of the five standard engagement rules (0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect): + +```java +warmup.addSendingAccount("sender@example.com", "self_managed", + new ZeroBounceWarmup.AddSendingAccountOptions().setEngagementRuleId(0), + onSuccess, onError); +``` + +A custom engagement rule can be changed only once every seven days; inside that window the API answers 429. + +```java +warmup.createCustomEngagement("sender@example.com", 20, 20, 35, onSuccess, onError); // reply, important, click % +``` + +`checkDomainDmarc` takes an **email address**, not a domain — the live API rejects a bare domain even though the docs describe one: + +```java +warmup.checkDomainDmarc("sender@example.com", null, onSuccess, onError); +``` + +Every documented empty success (204, or an empty 200) arrives as a `null` payload. A payload that is not a JSON object — `/seeds` is an array, `/seed-count` a bare number — comes back wrapped under `data`. Failures carry the HTTP status on `ErrorResponse.getStatusCode()`. Nothing is stored by `testSmtpConfiguration`, and the credentials you pass it are masked out of any error it raises. + +## Live API suite + +`mvn test` never touches the network: the live suite lives outside the Maven module's source root, so CI does not even compile it. It needs real keys, which come from the environment and are never written anywhere: + +```bash +mvn -f zero-bounce-sdk/pom.xml dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt +mvn -f zero-bounce-sdk/pom.xml compile +CP="zero-bounce-sdk/target/classes:$(cat /tmp/cp.txt)" +javac -cp "$CP" -d live/out live/LiveApi.java + +export ZEROBOUNCE_API_KEY=... # validation API +export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API +java -cp "$CP:live/out" com.zerobounce.live.LiveApi +``` + +By default it only makes free calls: sandbox addresses, reads, and requests that are expected to fail but still prove the API accepts the payload. It checks that the credit balance did not move. Two flags opt in to more: + +| Flag | What it adds | +| --- | --- | +| `ZEROBOUNCE_LIVE_SPEND=1` | billable calls (scoring, activity, email finder) and a bulk round trip that deletes its own job | +| `ZEROBOUNCE_LIVE_CHANGES=1` | reversible account changes — a test filter and a self-managed Warmup sending account — each undone in cleanup even when a test fails | + +It never creates an Inbox Placement test, sets the Inbox Placement webhook, or starts ZB-managed Warmup onboarding, because the API cannot undo any of those. + +Run it by hand before every release: the offline suite proves the SDK sends what we think it sends, and only the live suite proves the API accepts it. + ## Publish 1. Bump `sdk.version` in `zero-bounce-sdk/pom.xml`, commit, tag (`vX.Y.Z`), push tag. From cea613dc63bb43c35f840d724bde14f267ef4546 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:09:03 +0400 Subject: [PATCH 7/7] fix: api usage dropped two counters the API returns Found by auditing every SDK's api-usage model against the keys the live API actually returns, after the same class of bug turned up in Rust and iOS. sub_status_leading_period_removed and sub_status_alias_address are returned by getapiusage and were not read at all, so both were silently discarded. Co-Authored-By: Claude Opus 5 --- .../main/java/com/zerobounce/ZBGetApiUsageResponse.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBGetApiUsageResponse.java b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBGetApiUsageResponse.java index 1b4f47d..916c9ef 100644 --- a/zero-bounce-sdk/src/main/java/com/zerobounce/ZBGetApiUsageResponse.java +++ b/zero-bounce-sdk/src/main/java/com/zerobounce/ZBGetApiUsageResponse.java @@ -149,6 +149,14 @@ public class ZBGetApiUsageResponse { @SerializedName("sub_status_role_based_accept_all") private int subStatusRoleBasedAcceptAll = 0; + // Total emails with a leading period that was removed + @SerializedName("sub_status_leading_period_removed") + private int subStatusLeadingPeriodRemoved = 0; + + // Total number of times the API has a sub status of "alias_address" + @SerializedName("sub_status_alias_address") + private int subStatusAliasAddress = 0; + // Start date of query @SerializedName("start_date") @Nullable