diff --git a/README.md b/README.md index 8a94bf8..dcc3097 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,14 @@ Maven: com.anypost anypost-java - 1.1.0 + 1.5.0 ``` Gradle: ```groovy -implementation("com.anypost:anypost-java:1.1.0") +implementation("com.anypost:anypost-java:1.5.0") ``` ## Quickstart @@ -198,6 +198,18 @@ client.suppressions.create(SuppressionCreateParams.builder() client.suppressions.delete("alice@example.com", "marketing"); ``` +Add up to 1,000 at once with `createBatch`: + +```java +SuppressionBatchResult result = client.suppressions.createBatch(List.of( + SuppressionBatchItem.builder().email("alice@example.com").build(), + SuppressionBatchItem.builder().email("bob@example.com").reason("hard bounce").build())); + +System.out.println(result.imported() + " imported, " + result.alreadySuppressed() + " already suppressed"); +``` + +Addresses that are already suppressed are counted and left unchanged, so a batch is safe to retry. Invalid addresses come back in `result.invalid()` instead of failing the batch. + `get`, `list` (with `emailContains`, `topic`, `reason`, and `origin` filters), `listForEmail`, and `deleteForEmail` round out the resource. See [Suppressions](https://anypost.com/docs/reference/suppressions) for scoping and the automatic-suppression rules for bounces and complaints. ## Webhooks diff --git a/pom.xml b/pom.xml index 9b108ac..bafbd4e 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.anypost anypost-java - 1.4.0 + 1.5.0 jar Anypost Java SDK diff --git a/src/main/java/com/anypost/SuppressionsService.java b/src/main/java/com/anypost/SuppressionsService.java index eb8f7a7..40384d8 100644 --- a/src/main/java/com/anypost/SuppressionsService.java +++ b/src/main/java/com/anypost/SuppressionsService.java @@ -1,9 +1,12 @@ package com.anypost; import com.anypost.model.Suppression; +import com.anypost.model.SuppressionBatchItem; +import com.anypost.model.SuppressionBatchResult; import com.anypost.model.SuppressionCreateParams; import java.util.List; +import java.util.Map; /** * The {@code /suppressions} operations. Entries key on {@code (email, topic)}. @@ -17,7 +20,7 @@ public final class SuppressionsService { this.executor = executor; } - /** One page of the team's suppressions, newest-first. Expired rows are filtered out. */ + /** One page of the team's suppressions, newest-first. */ public Page list() { return list(SuppressionListParams.DEFAULT, null); } @@ -50,6 +53,22 @@ public Suppression create(SuppressionCreateParams params, RequestOptions options return executor.request("POST", "/suppressions", params, false, null, options, Suppression.class); } + /** + * Adds up to 1,000 suppressions in one call. Entries already suppressed are + * counted in {@code alreadySuppressed} and left unchanged, so a batch is safe to + * retry. Invalid addresses come back in {@code invalid} instead of failing the + * batch. Throws a validation error (nothing is added) if the batch is empty, + * has more than 1,000 entries, or would pass the team's suppression limit. + */ + public SuppressionBatchResult createBatch(List items) { + return createBatch(items, null); + } + + public SuppressionBatchResult createBatch(List items, RequestOptions options) { + return executor.request("POST", "/suppressions/batch", Map.of("suppressions", items), false, null, options, + SuppressionBatchResult.class); + } + /** * Retrieves the suppression for an {@code (email, topic)} pair. Use {@code "*"} * for the global row. Throws {@code NOT_FOUND} if the pair isn't suppressed. diff --git a/src/main/java/com/anypost/Version.java b/src/main/java/com/anypost/Version.java index 6479c7e..54cf282 100644 --- a/src/main/java/com/anypost/Version.java +++ b/src/main/java/com/anypost/Version.java @@ -8,7 +8,7 @@ * The release workflow publishes that tag to Maven Central. */ final class Version { - static final String VERSION = "1.4.0"; + static final String VERSION = "1.5.0"; private Version() {} } diff --git a/src/main/java/com/anypost/model/Suppression.java b/src/main/java/com/anypost/model/Suppression.java index 8ce438b..2eeb4f6 100644 --- a/src/main/java/com/anypost/model/Suppression.java +++ b/src/main/java/com/anypost/model/Suppression.java @@ -12,7 +12,7 @@ * @param smtpCode the SMTP reply code from the bounce, or {@code null} * @param note a free-form note attached at creation, or {@code null} * @param suppressedAt when the suppression was first observed - * @param expiresAt when it stops applying, or {@code null} for never + * @param expiresAt always {@code null}: suppressions do not expire. Kept for compatibility and will be removed in a future major version * @param createdAt the creation timestamp */ public record Suppression( diff --git a/src/main/java/com/anypost/model/SuppressionBatchInvalid.java b/src/main/java/com/anypost/model/SuppressionBatchInvalid.java new file mode 100644 index 0000000..f248aa0 --- /dev/null +++ b/src/main/java/com/anypost/model/SuppressionBatchInvalid.java @@ -0,0 +1,9 @@ +package com.anypost.model; + +/** + * A batch entry whose email is not a valid address. + * + * @param index the entry's 0-based position in the request + * @param email the address as sent, or {@code null} + */ +public record SuppressionBatchInvalid(int index, String email) {} diff --git a/src/main/java/com/anypost/model/SuppressionBatchItem.java b/src/main/java/com/anypost/model/SuppressionBatchItem.java new file mode 100644 index 0000000..719a4c1 --- /dev/null +++ b/src/main/java/com/anypost/model/SuppressionBatchItem.java @@ -0,0 +1,52 @@ +package com.anypost.model; + +/** + * One entry in a suppressions batch. + * + * @param email the address to suppress + * @param topic the topic to scope to, or {@code null} for {@code "*"} (every topic) + * @param reason a free-form reason; the server maps common values and aliases + * (e.g. {@code "hard bounce"}, {@code "spam report"}, {@code "opt-out"}) + * and records anything else as {@code manual}, or {@code null} + * @param note a free-form note (max 2000 characters), or {@code null} + */ +public record SuppressionBatchItem(String email, String topic, String reason, String note) { + + public static Builder builder() { + return new Builder(); + } + + /** A mutable builder for {@link SuppressionBatchItem}. */ + public static final class Builder { + private String email; + private String topic; + private String reason; + private String note; + + public Builder email(String email) { + this.email = email; + return this; + } + + /** Scope the suppression to one topic. Omit or {@code "*"} to block every topic. */ + public Builder topic(String topic) { + this.topic = topic; + return this; + } + + /** Why the address is suppressed. Aliases are accepted; unknown values record {@code manual}. */ + public Builder reason(String reason) { + this.reason = reason; + return this; + } + + public Builder note(String note) { + this.note = note; + return this; + } + + public SuppressionBatchItem build() { + return new SuppressionBatchItem(email, topic, reason, note); + } + } +} diff --git a/src/main/java/com/anypost/model/SuppressionBatchResult.java b/src/main/java/com/anypost/model/SuppressionBatchResult.java new file mode 100644 index 0000000..bdb32bf --- /dev/null +++ b/src/main/java/com/anypost/model/SuppressionBatchResult.java @@ -0,0 +1,14 @@ +package com.anypost.model; + +import java.util.List; + +/** + * Returned from a suppressions batch. {@code imported + alreadySuppressed + + * invalid.size()} equals the number of entries sent. + * + * @param imported the suppressions this call created + * @param alreadySuppressed entries whose address and topic were already suppressed, + * including repeats within the batch; left unchanged + * @param invalid entries with an invalid address, in request order + */ +public record SuppressionBatchResult(int imported, int alreadySuppressed, List invalid) {} diff --git a/src/test/java/com/anypost/SuppressionsTest.java b/src/test/java/com/anypost/SuppressionsTest.java new file mode 100644 index 0000000..398a5bf --- /dev/null +++ b/src/test/java/com/anypost/SuppressionsTest.java @@ -0,0 +1,60 @@ +package com.anypost; + +import com.anypost.model.SuppressionBatchItem; +import com.anypost.model.SuppressionBatchResult; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.anypost.TestSupport.MockTransport; +import static com.anypost.TestSupport.client; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +class SuppressionsTest { + + @Test + void createBatchPostsWrappedItemsAndParsesResult() throws Exception { + MockTransport transport = new MockTransport().enqueue(200, + "{\"imported\":1,\"already_suppressed\":0,\"invalid\":[{\"index\":1,\"email\":\"not-an-address\"}]}"); + + SuppressionBatchResult result = client(transport).suppressions.createBatch(List.of( + SuppressionBatchItem.builder().email("alice@example.com").build(), + SuppressionBatchItem.builder().email("not-an-address").build(), + SuppressionBatchItem.builder().email("bob@example.com").topic("marketing") + .reason("hard bounce").note("n").build())); + + assertEquals(1, result.imported()); + assertEquals(0, result.alreadySuppressed()); + assertEquals(1, result.invalid().size()); + assertEquals(1, result.invalid().get(0).index()); + assertEquals("not-an-address", result.invalid().get(0).email()); + + var request = transport.lastRequest(); + assertEquals("POST", request.method()); + assertEquals("https://api.test/v1/suppressions/batch", request.uri().toString()); + + JsonNode body = TestSupport.MAPPER.readTree(TestSupport.bodyOf(request)); + JsonNode items = body.get("suppressions"); + assertEquals(3, items.size()); + assertEquals("alice@example.com", items.get(0).get("email").asText()); + assertFalse(items.get(0).has("topic")); + assertFalse(items.get(0).has("reason")); + assertFalse(items.get(0).has("note")); + assertEquals("marketing", items.get(2).get("topic").asText()); + assertEquals("hard bounce", items.get(2).get("reason").asText()); + assertEquals("n", items.get(2).get("note").asText()); + } + + @Test + void createBatchParsesNullInvalidEmail() { + MockTransport transport = new MockTransport().enqueue(200, + "{\"imported\":0,\"already_suppressed\":1,\"invalid\":[{\"index\":0,\"email\":null}]}"); + SuppressionBatchResult result = client(transport).suppressions + .createBatch(List.of(SuppressionBatchItem.builder().build())); + assertEquals(1, result.alreadySuppressed()); + assertNull(result.invalid().get(0).email()); + } +}