Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ Maven:
<dependency>
<groupId>com.anypost</groupId>
<artifactId>anypost-java</artifactId>
<version>1.1.0</version>
<version>1.5.0</version>
</dependency>
```

Gradle:

```groovy
implementation("com.anypost:anypost-java:1.1.0")
implementation("com.anypost:anypost-java:1.5.0")
```

## Quickstart
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.anypost</groupId>
<artifactId>anypost-java</artifactId>
<version>1.4.0</version>
<version>1.5.0</version>
<packaging>jar</packaging>

<name>Anypost Java SDK</name>
Expand Down
21 changes: 20 additions & 1 deletion src/main/java/com/anypost/SuppressionsService.java
Original file line number Diff line number Diff line change
@@ -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)}.
Expand All @@ -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<Suppression> list() {
return list(SuppressionListParams.DEFAULT, null);
}
Expand Down Expand Up @@ -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<SuppressionBatchItem> items) {
return createBatch(items, null);
}

public SuppressionBatchResult createBatch(List<SuppressionBatchItem> 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.
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/anypost/Version.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
}
2 changes: 1 addition & 1 deletion src/main/java/com/anypost/model/Suppression.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/anypost/model/SuppressionBatchInvalid.java
Original file line number Diff line number Diff line change
@@ -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) {}
52 changes: 52 additions & 0 deletions src/main/java/com/anypost/model/SuppressionBatchItem.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/anypost/model/SuppressionBatchResult.java
Original file line number Diff line number Diff line change
@@ -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<SuppressionBatchInvalid> invalid) {}
60 changes: 60 additions & 0 deletions src/test/java/com/anypost/SuppressionsTest.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading