diff --git a/CHANGELOG.md b/CHANGELOG.md index cc89e10..851d80d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Covers the reworked bulk contact import (TPL-2105) and the duplicate-create fix. Everything here is additive — code written against 1.4.0 keeps compiling and sends the exact same payloads. + +### Added + +- **Per-contact bulk create.** `BulkCreateAudienceContactsOptions` supports a second request shape where each contact carries its own properties, lists and topic subscriptions, alongside the original flat `emails` list. Exactly one of the two must be filled in + + ```java + BulkCreateAudienceContactsOptions.builder() + .contacts(List.of( + BulkAudienceContactRow.builder() + .email("cara@example.com") + .properties(Map.of("plan", "pro")) + .build(), + BulkAudienceContactRow.builder() + .email("dan@example.com") + .topic(AudienceTopicSubscription.optOut("01h-promos")) + .build())) + .listIds(List.of("01h-everyone")) + .updateExisting(true) + .build(); + ``` +- `BulkAudienceContactRow` (builder: `email`, `properties`, `listIds`, `topics`/`topic`) and `AudienceTopicSubscription` (with the `optIn(id)` / `optOut(id)` constructors), plus the `AudienceTopicSubscriptionState` enum (`OPT_IN`, `OPT_OUT`) +- `AudienceTopicSubscriptionState` says what a request should *do* with a topic and is deliberately separate from `AudienceTopicDefaultSubscription`, which describes how a topic behaves for a contact that says nothing. An `optOut` on a topic whose default is opt-out suppresses the auto-subscription in the same request instead of needing a second call +- **Batch-wide `listIds` and `topics`,** plus `updateExisting`, on `BulkCreateAudienceContactsOptions`. Batch-wide lists and topics are unioned into every row; a row-level property key or opt-out wins over the batch-wide value. `updateExisting(true)` merges properties (submitted keys overwrite, absent keys are preserved) and allows dropping a subscription. It is only serialized when `true`, so a legacy payload stays byte-identical +- **Bulk create now reports what happened per row.** `BulkCreateAudienceContactsResponse` gains `getUpdated()`, `getErrorCount()`, `getErrors()` (`BulkAudienceContactError` — `index`, `email`, `errorCode`, `error`) and `getContacts()` (`BulkAudienceContactRef` — `id`, `email`, `created`), plus `hasErrors()`, `getContactIds()` and `findIdFor(email)`. `getCreated()` and `getAlreadyExisted()` keep their exact meaning, and the collection getters never return `null`, so the response also reads a pre-TPL-2105 body + + A bulk create can **partially succeed**: rows that fail validation are skipped and returned in `getErrors()` while the rest of the batch commits, and the call still returns HTTP 201. Check `hasErrors()` — a call that returns without throwing does not mean every row landed + + Note that `getAlreadyExisted()` and `getUpdated()` overlap by design. They answer different questions ("was the address already in the audience?" vs "did this request change the contact?"), so they do not sum to the row count: a contact that already existed and got attached to a list is counted in both +- `BulkAudienceContactErrorCode` enum (`missing_email`, `invalid_email`, `invalid_property_value`, `unknown_property_key`, `unknown_list`, `unknown_topic`, `invalid_topic_subscription`) with `fromWire(String)`. `BulkAudienceContactError.getErrorCode()` stays a raw `String` so a code added server-side survives; `getCode()` gives the typed form and returns `null` for an unknown code +- **Bulk topic subscribe/unsubscribe** — 2 new methods on `audience().contacts()`, mirroring the existing `bulkAttachToLists` / `bulkDetachFromLists` pair: + - `bulkSubscribeToTopics(BulkContactTopicsOptions)` — `POST /audience/contacts/topics/bulk`, returns `BulkSubscribeContactsResponse` (`subscribed`, `alreadySubscribed`, `totalPairs`) + - `bulkUnsubscribeFromTopics(BulkContactTopicsOptions)` — `DELETE /audience/contacts/topics/bulk` with a request body, returns `BulkUnsubscribeContactsResponse` (`unsubscribed`, `totalPairs`). Pairs that do not exist are ignored + + Both process every `contactIds` × `topicIds` combination (up to 1000 × 50). Feed them `getContactIds()` from a bulk create — no id lookup needed +- `ContactAlreadyExistsException` — thrown by `audience().contacts().create()` when the email is already in the team's audience. It carries the colliding `getEmail()`. This is a client-correctable condition, **not** an outage: do not retry it; update the existing contact, or use `bulkCreate()` with `updateExisting(true)` + +### Changed + +- Creating a contact whose email already exists now throws `ContactAlreadyExistsException` (HTTP 409, `resource_already_exists`). The API previously let this escape as HTTP 500 with the misleading `send_error` code, which arrived as a plain `LettrApiException`. **If your retry policy retries 5xx, duplicate creates are no longer retried** — which was pointless anyway. Any error mapping or docs of yours that name `send_error` for this endpoint should be corrected. The exception extends `LettrApiException`, so existing `catch (LettrApiException)` / `catch (LettrException)` handlers catch it unchanged, and a 409 with any other error code stays a plain `LettrApiException` +- `BulkCreateAudienceContactsOptions.getEmails()` is annotated `@Nullable` instead of `@Nonnull`, since it is now absent when the `contacts` shape is used. Source- and binary-compatible; only a static analyzer's view of it changes + ## [1.4.0] - 2026-05-28 ### Added diff --git a/src/main/java/com/lettr/core/exception/ContactAlreadyExistsException.java b/src/main/java/com/lettr/core/exception/ContactAlreadyExistsException.java new file mode 100644 index 0000000..6ce68a8 --- /dev/null +++ b/src/main/java/com/lettr/core/exception/ContactAlreadyExistsException.java @@ -0,0 +1,47 @@ +package com.lettr.core.exception; + +import javax.annotation.Nullable; + +/** + * Thrown when creating an audience contact whose email is already in the team's + * audience (HTTP 409, {@code resource_already_exists}). + * + *
This is a client-correctable condition, not an outage — do not retry + * it. Update the existing contact with {@code audience().contacts().update()}, + * or use {@code bulkCreate()} with {@code updateExisting(true)}. + * + *
Older API versions surfaced this as an HTTP 500 with the misleading + * {@code send_error} code, which arrived as a plain {@link LettrApiException}. + * Extending {@link LettrApiException} keeps existing + * {@code catch (LettrApiException)} and {@code catch (LettrException)} handlers + * working unchanged. + */ +public class ContactAlreadyExistsException extends LettrApiException { + + private final String email; + + public ContactAlreadyExistsException(String message, int statusCode, String errorCode, String email) { + super(message, statusCode, errorCode); + this.email = email; + } + + /** + * The address that collided, when the SDK knows it. + * + * @return the submitted email address, or null + */ + @Nullable + public String getEmail() { + return email; + } + + @Override + public String toString() { + return "ContactAlreadyExistsException{" + + "message='" + getMessage() + '\'' + + ", statusCode=" + getStatusCode() + + ", errorCode='" + getErrorCode() + '\'' + + ", email='" + email + '\'' + + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/AudienceContacts.java b/src/main/java/com/lettr/services/audience/contacts/AudienceContacts.java index d000eea..9de84e4 100644 --- a/src/main/java/com/lettr/services/audience/contacts/AudienceContacts.java +++ b/src/main/java/com/lettr/services/audience/contacts/AudienceContacts.java @@ -1,13 +1,18 @@ package com.lettr.services.audience.contacts; +import com.lettr.core.exception.ContactAlreadyExistsException; +import com.lettr.core.exception.LettrApiException; import com.lettr.core.exception.LettrException; import com.lettr.services.BaseService; import com.lettr.services.audience.contacts.model.AudienceContactView; import com.lettr.services.audience.contacts.model.BulkAttachContactsResponse; import com.lettr.services.audience.contacts.model.BulkContactListsOptions; +import com.lettr.services.audience.contacts.model.BulkContactTopicsOptions; import com.lettr.services.audience.contacts.model.BulkCreateAudienceContactsOptions; import com.lettr.services.audience.contacts.model.BulkCreateAudienceContactsResponse; import com.lettr.services.audience.contacts.model.BulkDetachContactsResponse; +import com.lettr.services.audience.contacts.model.BulkSubscribeContactsResponse; +import com.lettr.services.audience.contacts.model.BulkUnsubscribeContactsResponse; import com.lettr.services.audience.contacts.model.CreateAudienceContactOptions; import com.lettr.services.audience.contacts.model.ListAudienceContactsParams; import com.lettr.services.audience.contacts.model.ListAudienceContactsResponse; @@ -21,6 +26,12 @@ */ public class AudienceContacts extends BaseService { + /** + * The only documented 409 on {@code POST /audience/contacts} is a duplicate + * email. Any other conflict code the API grows later stays generic. + */ + private static final String RESOURCE_ALREADY_EXISTS = "resource_already_exists"; + public AudienceContacts(@Nonnull String apiKey) { super(apiKey); } @@ -48,16 +59,40 @@ public AudienceContactView get(@Nonnull String contactId) throws LettrException return httpClient.get("/audience/contacts/" + contactId, null, AudienceContactView.class); } - /** Create a single contact (optionally with double opt-in). */ + /** + * Create a single contact (optionally with double opt-in). + * + * @throws ContactAlreadyExistsException when the email is already in the + * team's audience. It extends {@link LettrApiException}, so existing + * handlers keep catching it. Do not retry — update the existing + * contact instead, or use {@link #bulkCreate} with + * {@code updateExisting(true)}. + */ @Nonnull public AudienceContactView create(@Nonnull CreateAudienceContactOptions options) throws LettrException { if (options == null) { throw new IllegalArgumentException("options is required"); } - return httpClient.post("/audience/contacts", options, AudienceContactView.class); + try { + return httpClient.post("/audience/contacts", options, AudienceContactView.class); + } catch (LettrApiException e) { + if (e.getStatusCode() == 409 + && (e.getErrorCode() == null || RESOURCE_ALREADY_EXISTS.equals(e.getErrorCode()))) { + throw new ContactAlreadyExistsException( + e.getMessage(), e.getStatusCode(), e.getErrorCode(), options.getEmail()); + } + throw e; + } } - /** Bulk create up to 1000 contacts. */ + /** + * Bulk create up to 1000 contacts. + * + *
Rows that fail validation are skipped, not fatal: the call still + * returns HTTP 201 and reports them in the response's {@code errors}. Check + * {@link BulkCreateAudienceContactsResponse#hasErrors()} — a call that + * returns without throwing does not mean every row landed. + */ @Nonnull public BulkCreateAudienceContactsResponse bulkCreate(@Nonnull BulkCreateAudienceContactsOptions options) throws LettrException { if (options == null) { @@ -137,6 +172,36 @@ public void subscribeToTopic(@Nonnull String contactId, @Nonnull String topicId) httpClient.post("/audience/contacts/" + contactId + "/topics/" + topicId, null); } + /** + * Bulk subscribe contacts to topics (cartesian product of contactIds × topicIds, + * up to 1000 × 50). + * + *
Pass {@link BulkCreateAudienceContactsResponse#getContactIds()} from a + * bulk create — no id lookup needed. + */ + @Nonnull + public BulkSubscribeContactsResponse bulkSubscribeToTopics(@Nonnull BulkContactTopicsOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.post("/audience/contacts/topics/bulk", options, BulkSubscribeContactsResponse.class); + } + + /** + * Bulk unsubscribe contacts from topics (cartesian product of + * contactIds × topicIds). Pairs that do not exist are ignored. + * + *
This is a DELETE carrying a request body, as + * {@link #bulkDetachFromLists} already is. + */ + @Nonnull + public BulkUnsubscribeContactsResponse bulkUnsubscribeFromTopics(@Nonnull BulkContactTopicsOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.delete("/audience/contacts/topics/bulk", options, BulkUnsubscribeContactsResponse.class); + } + /** Unsubscribe a contact from a topic. Idempotent. */ public void unsubscribeFromTopic(@Nonnull String contactId, @Nonnull String topicId) throws LettrException { if (contactId == null || contactId.isEmpty()) { diff --git a/src/main/java/com/lettr/services/audience/contacts/model/AudienceTopicSubscription.java b/src/main/java/com/lettr/services/audience/contacts/model/AudienceTopicSubscription.java new file mode 100644 index 0000000..af4d251 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/AudienceTopicSubscription.java @@ -0,0 +1,62 @@ +package com.lettr.services.audience.contacts.model; + +import javax.annotation.Nonnull; + +/** + * A topic and the subscription state to apply to it. + * + *
Used batch-wide on {@link BulkCreateAudienceContactsOptions} and per row on + * {@link BulkAudienceContactRow}. A row-level opt-out wins over a batch-level + * opt-in for that contact. + * + *
{@code
+ * AudienceTopicSubscription.optIn("01h-newsletter");
+ * AudienceTopicSubscription.optOut("01h-promos");
+ * }
+ */
+public class AudienceTopicSubscription {
+
+ private final String id;
+
+ private final AudienceTopicSubscriptionState subscription;
+
+ private AudienceTopicSubscription(String id, AudienceTopicSubscriptionState subscription) {
+ this.id = id;
+ this.subscription = subscription;
+ }
+
+ /** Subscribe the contact to the topic. */
+ @Nonnull
+ public static AudienceTopicSubscription optIn(@Nonnull String topicId) {
+ return of(topicId, AudienceTopicSubscriptionState.OPT_IN);
+ }
+
+ /**
+ * Suppress the topic for the contact — including a topic that would
+ * otherwise auto-subscribe newly created contacts.
+ */
+ @Nonnull
+ public static AudienceTopicSubscription optOut(@Nonnull String topicId) {
+ return of(topicId, AudienceTopicSubscriptionState.OPT_OUT);
+ }
+
+ @Nonnull
+ public static AudienceTopicSubscription of(@Nonnull String topicId,
+ @Nonnull AudienceTopicSubscriptionState subscription) {
+ if (topicId == null || topicId.isEmpty()) {
+ throw new IllegalArgumentException("topicId is required");
+ }
+ if (subscription == null) {
+ throw new IllegalArgumentException("subscription is required");
+ }
+ return new AudienceTopicSubscription(topicId, subscription);
+ }
+
+ @Nonnull public String getId() { return id; }
+ @Nonnull public AudienceTopicSubscriptionState getSubscription() { return subscription; }
+
+ @Override
+ public String toString() {
+ return "AudienceTopicSubscription{id='" + id + "', subscription=" + subscription + '}';
+ }
+}
diff --git a/src/main/java/com/lettr/services/audience/contacts/model/AudienceTopicSubscriptionState.java b/src/main/java/com/lettr/services/audience/contacts/model/AudienceTopicSubscriptionState.java
new file mode 100644
index 0000000..e2fb820
--- /dev/null
+++ b/src/main/java/com/lettr/services/audience/contacts/model/AudienceTopicSubscriptionState.java
@@ -0,0 +1,18 @@
+package com.lettr.services.audience.contacts.model;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * What a write request should do with a topic.
+ *
+ * Deliberately separate from + * {@link com.lettr.services.audience.topics.model.AudienceTopicDefaultSubscription}, + * which describes how a topic behaves for a contact that says nothing. + * {@link #OPT_OUT} here also cancels the auto-subscription a topic whose + * default is opt-out would otherwise give a newly created contact, so a create + * and an unsubscribe fit in one request. + */ +public enum AudienceTopicSubscriptionState { + @SerializedName("opt_in") OPT_IN, + @SerializedName("opt_out") OPT_OUT +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactError.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactError.java new file mode 100644 index 0000000..ec9f31b --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactError.java @@ -0,0 +1,58 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A row that was skipped during a bulk create, with its position in the + * submitted list. + * + *
The request still succeeds with HTTP 201 when rows are skipped — check + * {@link BulkCreateAudienceContactsResponse#hasErrors()} rather than the status. + */ +public class BulkAudienceContactError { + + private int index; + + private String email; + + @SerializedName("error_code") + private String errorCode; + + private String error; + + /** Zero-based position of the row in the submitted list. */ + public int getIndex() { return index; } + + @Nullable public String getEmail() { return email; } + + /** + * The raw {@code error_code}. Kept as a {@code String} so a code added + * server-side is still readable here; see {@link #getCode()} for the typed + * form. + */ + @Nullable public String getErrorCode() { return errorCode; } + + /** + * The typed error code, or {@code null} when the API reported one this SDK + * version does not know. + */ + @Nullable + public BulkAudienceContactErrorCode getCode() { + return BulkAudienceContactErrorCode.fromWire(errorCode); + } + + /** The human-readable reason the row was skipped. */ + @Nullable public String getError() { return error; } + + @Override + @Nonnull + public String toString() { + return "BulkAudienceContactError{index=" + index + + ", email='" + email + '\'' + + ", errorCode='" + errorCode + '\'' + + ", error='" + error + '\'' + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactErrorCode.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactErrorCode.java new file mode 100644 index 0000000..058b5de --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactErrorCode.java @@ -0,0 +1,53 @@ +package com.lettr.services.audience.contacts.model; + +import javax.annotation.Nullable; + +/** + * Reason a single row was skipped during a bulk contact create. + * + *
These are per-row codes reported inside a {@code 201} body — not the + * top-level {@code error_code} of a failed request. + * + *
{@link BulkAudienceContactError#getErrorCode()} stays a raw {@code String} + * so a code added server-side survives; use {@link #fromWire(String)} (or + * {@link BulkAudienceContactError#getCode()}) to match against these constants, + * and handle the {@code null} that an unknown code produces. + */ +public enum BulkAudienceContactErrorCode { + + MISSING_EMAIL("missing_email"), + INVALID_EMAIL("invalid_email"), + INVALID_PROPERTY_VALUE("invalid_property_value"), + UNKNOWN_PROPERTY_KEY("unknown_property_key"), + UNKNOWN_LIST("unknown_list"), + UNKNOWN_TOPIC("unknown_topic"), + INVALID_TOPIC_SUBSCRIPTION("invalid_topic_subscription"); + + private final String wireValue; + + BulkAudienceContactErrorCode(String wireValue) { + this.wireValue = wireValue; + } + + /** The value the API sends on the wire. */ + public String getWireValue() { + return wireValue; + } + + /** + * Resolves a wire value to a constant, or {@code null} when the API reports + * a code this SDK version does not know. + */ + @Nullable + public static BulkAudienceContactErrorCode fromWire(@Nullable String wireValue) { + if (wireValue == null) { + return null; + } + for (BulkAudienceContactErrorCode code : values()) { + if (code.wireValue.equals(wireValue)) { + return code; + } + } + return null; + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactRef.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactRef.java new file mode 100644 index 0000000..6e1946b --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactRef.java @@ -0,0 +1,30 @@ +package com.lettr.services.audience.contacts.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Identity of a contact that exists after a bulk create, so the caller can + * chain into the bulk list and topic endpoints without looking ids up again. + */ +public class BulkAudienceContactRef { + + private String id; + + private String email; + + private boolean created; + + @Nullable public String getId() { return id; } + + @Nullable public String getEmail() { return email; } + + /** {@code true} when this request created the contact, {@code false} when it already existed. */ + public boolean isCreated() { return created; } + + @Override + @Nonnull + public String toString() { + return "BulkAudienceContactRef{id='" + id + "', email='" + email + "', created=" + created + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactRow.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactRow.java new file mode 100644 index 0000000..d35ad78 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkAudienceContactRow.java @@ -0,0 +1,117 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * One contact in a bulk-create payload. + * + *
{@code listIds} and {@code topics} here are applied on top of the + * batch-wide ones on {@link BulkCreateAudienceContactsOptions}; a + * {@code properties} key here overrides the batch-wide value for the same key, + * and a row-level opt-out beats a batch-level opt-in. + * + *
A row that fails validation is skipped rather than failing the request —
+ * it comes back in {@link BulkCreateAudienceContactsResponse#getErrors()}.
+ */
+public class BulkAudienceContactRow {
+
+ private final String email;
+
+ private final Map A single instance serves both directions — see
+ * {@code AudienceContacts.bulkSubscribeToTopics} and
+ * {@code AudienceContacts.bulkUnsubscribeFromTopics}. Feed it
+ * {@link BulkCreateAudienceContactsResponse#getContactIds()} from a bulk create
+ * and no id lookup is needed.
+ */
+public class BulkContactTopicsOptions {
+
+ @SerializedName("contact_ids")
+ private final List Two shapes are supported, and exactly one of them must be filled in:
+ *
+ * Batch-wide {@code listIds} and {@code topics} are unioned into every row; a
+ * row-level property key or opt-out wins over the batch-wide value.
+ *
+ * A bulk create can partially succeed: rows that fail validation are
+ * skipped and reported in {@link #getErrors()}, while the rest of the batch is
+ * still written. The call returns HTTP 201 either way, so a method that returns
+ * without throwing does not mean every row landed — check
+ * {@link #hasErrors()}.
+ *
+ * {@link #getAlreadyExisted()} and {@link #getUpdated()} overlap by design.
+ * They answer different questions ("was the address already in the audience?"
+ * vs "did this request change the contact?"), so the counters do not sum to the
+ * row count: a contact that already existed and got attached to a list is
+ * counted in both.
+ */
public class BulkCreateAudienceContactsResponse {
private int created;
@@ -9,6 +31,15 @@ public class BulkCreateAudienceContactsResponse {
@SerializedName("already_existed")
private int alreadyExisted;
+ private int updated;
+
+ @SerializedName("error_count")
+ private int errorCount;
+
+ private List Pairs that did not exist are ignored, so {@link #getUnsubscribed()} can be
+ * lower than {@link #getTotalPairs()}.
+ */
+public class BulkUnsubscribeContactsResponse {
+
+ private int unsubscribed;
+
+ @SerializedName("total_pairs")
+ private int totalPairs;
+
+ public int getUnsubscribed() { return unsubscribed; }
+ public int getTotalPairs() { return totalPairs; }
+
+ @Override
+ public String toString() {
+ return "BulkUnsubscribeContactsResponse{unsubscribed=" + unsubscribed
+ + ", totalPairs=" + totalPairs + '}';
+ }
+}
diff --git a/src/test/java/com/lettr/services/audience/contacts/AudienceContactsTest.java b/src/test/java/com/lettr/services/audience/contacts/AudienceContactsTest.java
index 320b9f0..9b17aca 100644
--- a/src/test/java/com/lettr/services/audience/contacts/AudienceContactsTest.java
+++ b/src/test/java/com/lettr/services/audience/contacts/AudienceContactsTest.java
@@ -2,6 +2,9 @@
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
+import com.lettr.core.exception.ContactAlreadyExistsException;
+import com.lettr.core.exception.LettrApiException;
+import com.lettr.core.exception.LettrException;
import com.lettr.services.audience.contacts.model.*;
import org.junit.jupiter.api.Test;
@@ -126,6 +129,187 @@ void bulkCreateOptionsValidatesEmailsBounds() {
.emails(Arrays.asList("a@b.com", "c@d.com")).build());
}
+ // --- TPL-2105: bulk contact import ---
+
+ @Test
+ void bulkCreateOptionsRequiresEitherEmailsOrContacts() {
+ assertThrows(IllegalArgumentException.class,
+ () -> BulkCreateAudienceContactsOptions.builder().listId("l1").build());
+
+ // Either shape on its own is enough.
+ assertNotNull(BulkCreateAudienceContactsOptions.builder()
+ .emails(Arrays.asList("a@b.com")).build());
+ assertNotNull(BulkCreateAudienceContactsOptions.builder()
+ .contacts(Arrays.asList(BulkAudienceContactRow.of("a@b.com"))).build());
+ }
+
+ @Test
+ void bulkCreateOptionsKeepsTheLegacyPayloadByteIdentical() {
+ // A pre-TPL-2105 call must serialize exactly as it did before: no
+ // "contacts" key, and no "update_existing" unless it was asked for.
+ String json = gson.toJson(BulkCreateAudienceContactsOptions.builder()
+ .emails(Arrays.asList("a@b.com", "c@d.com"))
+ .listId("l1")
+ .build());
+
+ assertEquals("{\"emails\":[\"a@b.com\",\"c@d.com\"],\"list_id\":\"l1\"}", json);
+ }
+
+ @Test
+ void bulkCreateOptionsSerializesPerContactRows() {
+ Map
+ *
+ *
+ * {@code
+ * BulkCreateAudienceContactsOptions.builder()
+ * .contacts(List.of(
+ * BulkAudienceContactRow.builder()
+ * .email("cara@example.com")
+ * .properties(Map.of("plan", "pro"))
+ * .build(),
+ * BulkAudienceContactRow.builder()
+ * .email("dan@example.com")
+ * .topic(AudienceTopicSubscription.optOut("01h-promos"))
+ * .build()))
+ * .listIds(List.of("01h-everyone"))
+ * .updateExisting(true)
+ * .build();
+ * }
+ *
+ * @see BulkCreateAudienceContactsResponse for how partial failures are reported.
*/
public class BulkCreateAudienceContactsOptions {
@@ -21,10 +52,27 @@ public class BulkCreateAudienceContactsOptions {
private final Map