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());
+ }
+}