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 properties; + + @SerializedName("list_ids") + private final List listIds; + + private final List topics; + + private BulkAudienceContactRow(Builder builder) { + this.email = builder.email; + this.properties = builder.properties; + this.listIds = builder.listIds; + this.topics = builder.topics; + } + + /** A row with nothing but an address — it inherits everything batch-wide. */ + @Nonnull + public static BulkAudienceContactRow of(@Nonnull String email) { + return builder().email(email).build(); + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull public String getEmail() { return email; } + @Nullable public Map getProperties() { return properties; } + @Nullable public List getListIds() { return listIds; } + @Nullable public List getTopics() { return topics; } + + public static class Builder { + private String email; + private Map properties; + private List listIds; + private List topics; + + private Builder() {} + + /** (required) The contact's email address. */ + @Nonnull + public Builder email(@Nonnull String email) { + this.email = email; + return this; + } + + /** + * (optional) Property values for this contact. Each key must match + * a property defined for the team, and wins over the batch-wide value. + */ + @Nonnull + public Builder properties(@Nullable Map properties) { + this.properties = properties == null ? null : new LinkedHashMap<>(properties); + return this; + } + + /** (optional) Up to 50 lists for this row, on top of the batch-wide ones. */ + @Nonnull + public Builder listIds(@Nullable List listIds) { + this.listIds = listIds == null ? null : new ArrayList<>(listIds); + return this; + } + + /** (optional) Up to 50 topic subscriptions for this row. */ + @Nonnull + public Builder topics(@Nullable List topics) { + this.topics = topics == null ? null : new ArrayList<>(topics); + return this; + } + + /** (optional) Convenience for a single topic subscription. */ + @Nonnull + public Builder topic(@Nonnull AudienceTopicSubscription topic) { + return topics(Collections.singletonList(topic)); + } + + @Nonnull + public BulkAudienceContactRow build() { + if (email == null || email.isEmpty()) { + throw new IllegalArgumentException("email is required"); + } + if (listIds != null && listIds.size() > 50) { + throw new IllegalArgumentException("listIds cannot contain more than 50 ids"); + } + if (topics != null && topics.size() > 50) { + throw new IllegalArgumentException("topics cannot contain more than 50 subscriptions"); + } + return new BulkAudienceContactRow(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkContactTopicsOptions.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkContactTopicsOptions.java new file mode 100644 index 0000000..d98b93e --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkContactTopicsOptions.java @@ -0,0 +1,51 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; + +/** + * Shared request body for bulk subscribing or unsubscribing contacts and topics. + * The endpoint applies the cartesian product of {@code contactIds} × {@code topicIds}. + * + *

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 contactIds; + + @SerializedName("topic_ids") + private final List topicIds; + + private BulkContactTopicsOptions(List contactIds, List topicIds) { + this.contactIds = contactIds; + this.topicIds = topicIds; + } + + @Nonnull + public static BulkContactTopicsOptions of(@Nonnull List contactIds, @Nonnull List topicIds) { + if (contactIds == null || contactIds.isEmpty()) { + throw new IllegalArgumentException("contactIds must contain at least one id"); + } + if (contactIds.size() > 1000) { + throw new IllegalArgumentException("contactIds cannot contain more than 1000 ids"); + } + if (topicIds == null || topicIds.isEmpty()) { + throw new IllegalArgumentException("topicIds must contain at least one id"); + } + if (topicIds.size() > 50) { + throw new IllegalArgumentException("topicIds cannot contain more than 50 ids"); + } + return new BulkContactTopicsOptions(new ArrayList<>(contactIds), new ArrayList<>(topicIds)); + } + + @Nonnull public List getContactIds() { return contactIds; } + @Nonnull public List getTopicIds() { return topicIds; } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsOptions.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsOptions.java index 8fc6df7..064b09e 100644 --- a/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsOptions.java +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsOptions.java @@ -11,6 +11,37 @@ /** * Request body for bulk-creating up to 1000 audience contacts. + * + *

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. + * + *

{@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 properties; + private final List contacts; + + @SerializedName("list_ids") + private final List listIds; + + private final List topics; + + // Boxed so it can stay null and be omitted from the payload when false — + // a legacy request then serializes byte-identically. The API defaults it + // to false anyway. + @SerializedName("update_existing") + private final Boolean updateExisting; + private BulkCreateAudienceContactsOptions(Builder builder) { this.emails = builder.emails; this.listId = builder.listId; this.properties = builder.properties; + this.contacts = builder.contacts; + this.listIds = builder.listIds; + this.topics = builder.topics; + this.updateExisting = builder.updateExisting ? Boolean.TRUE : null; } @Nonnull @@ -32,46 +80,113 @@ public static Builder builder() { return new Builder(); } - @Nonnull public List getEmails() { return emails; } + @Nullable public List getEmails() { return emails; } @Nullable public String getListId() { return listId; } @Nullable public Map getProperties() { return properties; } + @Nullable public List getContacts() { return contacts; } + @Nullable public List getListIds() { return listIds; } + @Nullable public List getTopics() { return topics; } + public boolean isUpdateExisting() { return Boolean.TRUE.equals(updateExisting); } public static class Builder { private List emails; private String listId; private Map properties; + private List contacts; + private List listIds; + private List topics; + private boolean updateExisting; private Builder() {} - /** (required) 1–1000 email addresses. */ + /** + * 1–1000 email addresses that all share the batch-wide settings. + * Required unless {@link #contacts(List)} is used. + */ @Nonnull - public Builder emails(@Nonnull List emails) { + public Builder emails(@Nullable List emails) { this.emails = emails == null ? null : new ArrayList<>(emails); return this; } - /** (optional) Add all created contacts to this list. */ + /** (optional) Add all contacts to this list. Folded into {@code listIds} server-side. */ @Nonnull public Builder listId(@Nullable String listId) { this.listId = listId; return this; } - /** (optional) Custom property values applied to every created contact. */ + /** + * (optional) Property values applied to every contact in the + * batch. A row's own key wins over these. + */ @Nonnull public Builder properties(@Nullable Map properties) { this.properties = properties == null ? null : new LinkedHashMap<>(properties); return this; } + /** + * 1–1000 rows, each with its own properties, lists and topic + * subscriptions. Required unless {@link #emails(List)} is used. + */ + @Nonnull + public Builder contacts(@Nullable List contacts) { + this.contacts = contacts == null ? null : new ArrayList<>(contacts); + return this; + } + + /** + * (optional) Up to 50 batch-wide lists, unioned into every row on + * top of the row's own {@code listIds}. + */ + @Nonnull + public Builder listIds(@Nullable List listIds) { + this.listIds = listIds == null ? null : new ArrayList<>(listIds); + return this; + } + + /** (optional) Up to 50 batch-wide topic subscriptions. */ + @Nonnull + public Builder topics(@Nullable List topics) { + this.topics = topics == null ? null : new ArrayList<>(topics); + return this; + } + + /** + * (optional) When {@code true}, existing contacts have their + * properties merged (submitted keys overwrite, absent keys are + * preserved) and opt-outs applied. Defaults to {@code false}, in which + * case existing contacts keep their properties but are still attached to + * the requested lists. + */ + @Nonnull + public Builder updateExisting(boolean updateExisting) { + this.updateExisting = updateExisting; + return this; + } + @Nonnull public BulkCreateAudienceContactsOptions build() { - if (emails == null || emails.isEmpty()) { - throw new IllegalArgumentException("emails must contain at least one address"); + boolean hasEmails = emails != null && !emails.isEmpty(); + boolean hasContacts = contacts != null && !contacts.isEmpty(); + + if (!hasEmails && !hasContacts) { + throw new IllegalArgumentException( + "either emails or contacts must contain at least one entry"); } - if (emails.size() > 1000) { + if (hasEmails && emails.size() > 1000) { throw new IllegalArgumentException("emails cannot contain more than 1000 addresses"); } + if (hasContacts && contacts.size() > 1000) { + throw new IllegalArgumentException("contacts cannot contain more than 1000 rows"); + } + if (listIds != null && listIds.size() > 50) { + throw new IllegalArgumentException("listIds cannot contain more than 50 ids"); + } + if (topics != null && topics.size() > 50) { + throw new IllegalArgumentException("topics cannot contain more than 50 subscriptions"); + } return new BulkCreateAudienceContactsOptions(this); } } diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsResponse.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsResponse.java index 317f260..a39aed9 100644 --- a/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsResponse.java +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsResponse.java @@ -2,6 +2,28 @@ 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.List; +import java.util.Locale; + +/** + * Result of a bulk contact create. + * + *

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 errors; + + private List contacts; + public int getCreated() { return created; } @@ -17,8 +48,83 @@ public int getAlreadyExisted() { return alreadyExisted; } + /** + * Existing contacts this request changed — properties merged, a list or + * topic attached, or a subscription dropped. + */ + public int getUpdated() { + return updated; + } + + /** Number of skipped rows. */ + public int getErrorCount() { + return errorCount; + } + + /** + * The skipped rows. Never {@code null} — an API deployment that predates + * TPL-2105 omits the field, which reads as an empty list here. + */ + @Nonnull + public List getErrors() { + return errors == null ? Collections.emptyList() : errors; + } + + /** + * Every contact that exists after the request, in submission order. Never + * {@code null}. + */ + @Nonnull + public List getContacts() { + return contacts == null ? Collections.emptyList() : contacts; + } + + /** + * Whether any row was skipped. Always check this — a bulk create reports + * partial failures in the body, not in the HTTP status. + */ + public boolean hasErrors() { + return !getErrors().isEmpty(); + } + + /** + * The ids of every contact that exists after the request, in submission + * order — ready to feed into the bulk list and topic endpoints. + */ + @Nonnull + public List getContactIds() { + List ids = new ArrayList<>(); + for (BulkAudienceContactRef contact : getContacts()) { + ids.add(contact.getId()); + } + return ids; + } + + /** + * Looks up the id for a submitted address, or {@code null} when it is not in + * the response. Matching is case-insensitive because the API normalizes + * addresses before storing them. + */ + @Nullable + public String findIdFor(@Nullable String email) { + if (email == null) { + return null; + } + String needle = email.trim().toLowerCase(Locale.ROOT); + for (BulkAudienceContactRef contact : getContacts()) { + if (contact.getEmail() != null && contact.getEmail().toLowerCase(Locale.ROOT).equals(needle)) { + return contact.getId(); + } + } + return null; + } + @Override public String toString() { - return "BulkCreateAudienceContactsResponse{created=" + created + ", alreadyExisted=" + alreadyExisted + '}'; + return "BulkCreateAudienceContactsResponse{created=" + created + + ", alreadyExisted=" + alreadyExisted + + ", updated=" + updated + + ", errorCount=" + errorCount + + ", contacts=" + getContacts().size() + '}'; } } diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkSubscribeContactsResponse.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkSubscribeContactsResponse.java new file mode 100644 index 0000000..79f8559 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkSubscribeContactsResponse.java @@ -0,0 +1,26 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +/** Counts from a bulk topic subscribe over {@code contactIds} × {@code topicIds}. */ +public class BulkSubscribeContactsResponse { + + private int subscribed; + + @SerializedName("already_subscribed") + private int alreadySubscribed; + + @SerializedName("total_pairs") + private int totalPairs; + + public int getSubscribed() { return subscribed; } + public int getAlreadySubscribed() { return alreadySubscribed; } + public int getTotalPairs() { return totalPairs; } + + @Override + public String toString() { + return "BulkSubscribeContactsResponse{subscribed=" + subscribed + + ", alreadySubscribed=" + alreadySubscribed + + ", totalPairs=" + totalPairs + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkUnsubscribeContactsResponse.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkUnsubscribeContactsResponse.java new file mode 100644 index 0000000..0b3a0fa --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkUnsubscribeContactsResponse.java @@ -0,0 +1,26 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +/** + * Counts from a bulk topic unsubscribe over {@code contactIds} × {@code topicIds}. + * + *

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 rowProps = new LinkedHashMap<>(); + rowProps.put("plan", "pro"); + + BulkCreateAudienceContactsOptions options = BulkCreateAudienceContactsOptions.builder() + .contacts(Arrays.asList( + BulkAudienceContactRow.builder() + .email("cara@example.com") + .properties(rowProps) + .listIds(Arrays.asList("l-vip")) + .build(), + // Row-level opt-out must beat the batch-wide opt-in below. + BulkAudienceContactRow.builder() + .email("dan@example.com") + .topic(AudienceTopicSubscription.optOut("t-promos")) + .build())) + .listIds(Arrays.asList("l-everyone")) + .topics(Arrays.asList(AudienceTopicSubscription.optIn("t-promos"))) + .updateExisting(true) + .build(); + + String json = gson.toJson(options); + + assertFalse(json.contains("\"emails\""), json); + assertTrue(json.contains("\"email\":\"cara@example.com\""), json); + assertTrue(json.contains("\"list_ids\":[\"l-vip\"]"), json); + assertTrue(json.contains("\"topics\":[{\"id\":\"t-promos\",\"subscription\":\"opt_out\"}]"), json); + assertTrue(json.contains("\"list_ids\":[\"l-everyone\"]"), json); + assertTrue(json.contains("\"subscription\":\"opt_in\""), json); + assertTrue(json.contains("\"update_existing\":true"), json); + } + + @Test + void topicSubscriptionRejectsMissingId() { + assertThrows(IllegalArgumentException.class, () -> AudienceTopicSubscription.optIn("")); + assertThrows(IllegalArgumentException.class, () -> AudienceTopicSubscription.optOut(null)); + assertEquals(AudienceTopicSubscriptionState.OPT_OUT, + AudienceTopicSubscription.optOut("t1").getSubscription()); + } + + @Test + void bulkCreateResponseDeserializesTheNewFields() { + String json = "{\"created\":2,\"already_existed\":1,\"updated\":1,\"error_count\":0," + + "\"errors\":[]," + + "\"contacts\":[{\"id\":\"c1\",\"email\":\"Cara@example.com\",\"created\":true}," + + "{\"id\":\"c2\",\"email\":\"dan@example.com\",\"created\":false}]}"; + + BulkCreateAudienceContactsResponse response = + gson.fromJson(json, BulkCreateAudienceContactsResponse.class); + + assertEquals(1, response.getUpdated()); + assertEquals(0, response.getErrorCount()); + assertFalse(response.hasErrors()); + // Ids come back in submission order, so no follow-up lookup is needed. + assertEquals(Arrays.asList("c1", "c2"), response.getContactIds()); + assertFalse(response.getContacts().get(1).isCreated()); + // findIdFor is case-insensitive: the API normalizes addresses. + assertEquals("c1", response.findIdFor(" cara@EXAMPLE.com ")); + assertNull(response.findIdFor("nobody@example.com")); + } + + @Test + void bulkCreateResponseTreatsOmittedFieldsAsEmpty() { + // An API deployment older than TPL-2105 answers with just the two + // counters. hasErrors() and getContactIds() must still be usable. + BulkCreateAudienceContactsResponse response = gson.fromJson( + "{\"created\":2,\"already_existed\":1}", BulkCreateAudienceContactsResponse.class); + + assertEquals(0, response.getUpdated()); + assertFalse(response.hasErrors()); + assertNotNull(response.getErrors()); + assertTrue(response.getContactIds().isEmpty()); + } + + @Test + void bulkCreateResponseReportsSkippedRows() { + // Partial success: HTTP 201 with errors populated. Nothing throws, even + // though one row never landed — that is the trap this pins down. + String json = "{\"created\":1,\"already_existed\":0,\"updated\":0,\"error_count\":1," + + "\"errors\":[{\"index\":1,\"email\":\"not-an-email\"," + + "\"error_code\":\"invalid_email\",\"error\":\"The email address is not valid.\"}]," + + "\"contacts\":[{\"id\":\"c1\",\"email\":\"cara@example.com\",\"created\":true}]}"; + + BulkCreateAudienceContactsResponse response = + gson.fromJson(json, BulkCreateAudienceContactsResponse.class); + + assertTrue(response.hasErrors()); + assertEquals(1, response.getErrorCount()); + assertEquals(1, response.getErrors().get(0).getIndex()); + assertEquals(BulkAudienceContactErrorCode.INVALID_EMAIL, response.getErrors().get(0).getCode()); + assertEquals(1, response.getContacts().size()); + } + + @Test + void bulkContactErrorSurvivesAnUnknownCode() { + // A code added server-side must stay readable rather than failing to parse. + String json = "{\"created\":0,\"already_existed\":0,\"error_count\":1," + + "\"errors\":[{\"index\":0,\"email\":null," + + "\"error_code\":\"some_future_code\",\"error\":\"Nope.\"}]}"; + + BulkCreateAudienceContactsResponse response = + gson.fromJson(json, BulkCreateAudienceContactsResponse.class); + + assertEquals("some_future_code", response.getErrors().get(0).getErrorCode()); + assertNull(response.getErrors().get(0).getCode()); + assertNull(response.getErrors().get(0).getEmail()); + } + + @Test + void bulkContactTopicsOptionsValidatesBounds() { + assertThrows(IllegalArgumentException.class, + () -> BulkContactTopicsOptions.of(Collections.emptyList(), Arrays.asList("t1"))); + assertThrows(IllegalArgumentException.class, + () -> BulkContactTopicsOptions.of(Arrays.asList("c1"), Collections.emptyList())); + + BulkContactTopicsOptions options = BulkContactTopicsOptions.of( + Arrays.asList("c1", "c2"), Arrays.asList("t1", "t2")); + assertEquals("{\"contact_ids\":[\"c1\",\"c2\"],\"topic_ids\":[\"t1\",\"t2\"]}", + gson.toJson(options)); + } + + @Test + void bulkTopicResponsesDeserialize() { + BulkSubscribeContactsResponse subscribed = gson.fromJson( + "{\"subscribed\":3,\"already_subscribed\":1,\"total_pairs\":4}", + BulkSubscribeContactsResponse.class); + // 2 contacts × 2 topics — the endpoint works over the cartesian product. + assertEquals(3, subscribed.getSubscribed()); + assertEquals(1, subscribed.getAlreadySubscribed()); + assertEquals(4, subscribed.getTotalPairs()); + + BulkUnsubscribeContactsResponse unsubscribed = gson.fromJson( + "{\"unsubscribed\":2,\"total_pairs\":4}", + BulkUnsubscribeContactsResponse.class); + // Pairs that did not exist are ignored, so this is below totalPairs. + assertEquals(2, unsubscribed.getUnsubscribed()); + assertEquals(4, unsubscribed.getTotalPairs()); + } + + @Test + void contactAlreadyExistsExceptionIsAnApiException() { + // The 409 replaces a 500 send_error. Subclassing LettrApiException keeps + // pre-existing catch blocks working. + ContactAlreadyExistsException e = new ContactAlreadyExistsException( + "A contact with the email jane@example.com already exists.", + 409, "resource_already_exists", "jane@example.com"); + + assertTrue(e instanceof LettrApiException); + assertTrue(e instanceof LettrException); + assertEquals(409, e.getStatusCode()); + assertEquals("resource_already_exists", e.getErrorCode()); + assertEquals("jane@example.com", e.getEmail()); + } + @Test void bulkContactListsOptionsValidatesBounds() { assertThrows(IllegalArgumentException.class, @@ -190,5 +374,7 @@ void serviceArgumentValidation() { assertThrows(IllegalArgumentException.class, () -> svc.unsubscribeFromTopic(null, "t1")); assertThrows(IllegalArgumentException.class, () -> svc.bulkAttachToLists(null)); assertThrows(IllegalArgumentException.class, () -> svc.bulkDetachFromLists(null)); + assertThrows(IllegalArgumentException.class, () -> svc.bulkSubscribeToTopics(null)); + assertThrows(IllegalArgumentException.class, () -> svc.bulkUnsubscribeFromTopics(null)); } }