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
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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}).
*
* <p>This is a client-correctable condition, not an outage — <b>do not retry
* it.</b> Update the existing contact with {@code audience().contacts().update()},
* or use {@code bulkCreate()} with {@code updateExisting(true)}.
*
* <p>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 + '\'' +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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.
*
* <p>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) {
Expand Down Expand Up @@ -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).
*
* <p>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.
*
* <p>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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <pre>{@code
* AudienceTopicSubscription.optIn("01h-newsletter");
* AudienceTopicSubscription.optOut("01h-promos");
* }</pre>
*/
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 + '}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.lettr.services.audience.contacts.model;

import com.google.gson.annotations.SerializedName;

/**
* What a write request should <em>do</em> with a topic.
*
* <p>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
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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 + '\'' + '}';
}
}
Loading
Loading