Skip to content

Commit f9d682c

Browse files
committed
feat: add /audience SDK routes
1 parent 487d380 commit f9d682c

59 files changed

Lines changed: 3147 additions & 5 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ build/
1313
.project
1414
.classpath
1515
.cursor/
16+
bin/
1617

1718
# OS
1819
.DS_Store

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.2.0] - 2026-05-25
9+
10+
### Added
11+
12+
- **Audience** namespace covering 33 endpoints across five sub-services, reached via `lettr.audience()`:
13+
- `lists()` — list, get, create, update, delete, bulk delete
14+
- `contacts()` — list, get, create (with optional double opt-in), bulk create, update, delete, attach/detach to lists (single + bulk), subscribe/unsubscribe to topics
15+
- `topics()` — list, get, create, update, delete
16+
- `properties()` — list, get, create, update, delete
17+
- `segments()` — list, get, create, update, delete (with full `SegmentOperator` / `SegmentCondition` / `SegmentConditionGroup` modelling)
18+
- `HttpClient.patch(path, body, type)` — used by every audience update endpoint
19+
- `HttpClient.delete(path, body, type)` — used by `/audience/lists/bulk` and `/audience/contacts/lists/bulk`
20+
- `HttpClient.post(path, body)` — void overload for attach/subscribe endpoints that return only `{message}`
21+
- `NullablePropertiesAdapter` — preserves `null` map values when serializing `UpdateAudienceContactOptions.properties`, so the spec's "set a property to null to delete it" semantics actually work
22+
- `UpdateAudiencePropertyOptionsAdapter` — always emits `fallback_value`, so `UpdateAudiencePropertyOptions.withFallbackValue(null)` clears the fallback instead of being silently dropped
23+
- 46 unit tests covering the new audience namespace (Gson deserialization + argument validation)
24+
25+
### Notes
26+
27+
- `UpdateAudienceContactOptions.status(...)` now rejects `BOUNCED`, `COMPLAINED`, and `UNVERIFIED` at builder time — the API only accepts `SUBSCRIBED` / `UNSUBSCRIBED` for updates; the other statuses are server-managed
28+
- `/audience/confirm/{token}` is intentionally not covered (token-flow endpoint)
29+
830
## [1.1.0] - 2026-04-22
931

1032
### Added

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The official Java SDK for the [Lettr](https://lettr.com) Email API. Send transac
77
### Gradle
88

99
```groovy
10-
implementation 'com.lettr:lettr-java:1.0.0'
10+
implementation 'com.lettr:lettr-java:1.2.0'
1111
```
1212

1313
### Maven
@@ -16,7 +16,7 @@ implementation 'com.lettr:lettr-java:1.0.0'
1616
<dependency>
1717
<groupId>com.lettr</groupId>
1818
<artifactId>lettr-java</artifactId>
19-
<version>1.0.0</version>
19+
<version>1.2.0</version>
2020
</dependency>
2121
```
2222

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
GROUP=com.lettr
2-
VERSION=1.1.0
2+
VERSION=1.2.0
33
POM_ARTIFACT_ID=lettr-java
44
POM_NAME=Lettr Java SDK
55
POM_DESCRIPTION=Java SDK for the Lettr Email API

src/main/java/com/lettr/Lettr.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.lettr;
22

3+
import com.lettr.services.audience.Audience;
34
import com.lettr.services.domains.Domains;
45
import com.lettr.services.emails.Emails;
56
import com.lettr.services.projects.Projects;
@@ -63,4 +64,7 @@ public Lettr(@Nonnull String apiKey) {
6364

6465
/** Returns the System service for health checks and API key validation. */
6566
@Nonnull public System system() { return new System(apiKey); }
67+
68+
/** Returns the Audience namespace for managing lists, contacts, topics, properties, and segments. */
69+
@Nonnull public Audience audience() { return new Audience(apiKey); }
6670
}

src/main/java/com/lettr/core/net/HttpClient.java

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
public class HttpClient {
2828

2929
private static final String BASE_URL = "https://app.lettr.com/api";
30-
private static final String USER_AGENT = "lettr-java/1.0.0";
30+
private static final String USER_AGENT = "lettr-java/1.2.0";
3131
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30);
3232

3333
private final String apiKey;
@@ -123,14 +123,66 @@ public <T> T put(String path, Object body, Type responseType) throws LettrExcept
123123
return execute(request, responseType);
124124
}
125125

126+
/**
127+
* Perform a POST request without expecting a deserialized response body.
128+
* Useful for endpoints that return only a {@code {"message": "..."}} envelope.
129+
*
130+
* @param path API path
131+
* @param body request body object (will be serialized to JSON, or {@code null} for no body)
132+
* @throws LettrException on error
133+
*/
134+
public void post(String path, Object body) throws LettrException {
135+
String url = buildUrl(path, null);
136+
String jsonBody = gson.toJson(body);
137+
138+
HttpRequest request = HttpRequest.newBuilder()
139+
.uri(URI.create(url))
140+
.timeout(DEFAULT_TIMEOUT)
141+
.header("Authorization", "Bearer " + apiKey)
142+
.header("Content-Type", "application/json")
143+
.header("Accept", "application/json")
144+
.header("User-Agent", USER_AGENT)
145+
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
146+
.build();
147+
148+
executeNoResponse(request);
149+
}
150+
151+
/**
152+
* Perform a PATCH request with a JSON body.
153+
*
154+
* @param path API path
155+
* @param body request body object (will be serialized to JSON)
156+
* @param responseType the type to deserialize the "data" field into
157+
* @param <T> response data type
158+
* @return deserialized response data
159+
* @throws LettrException on error
160+
*/
161+
public <T> T patch(String path, Object body, Type responseType) throws LettrException {
162+
String url = buildUrl(path, null);
163+
String jsonBody = gson.toJson(body);
164+
165+
HttpRequest request = HttpRequest.newBuilder()
166+
.uri(URI.create(url))
167+
.timeout(DEFAULT_TIMEOUT)
168+
.header("Authorization", "Bearer " + apiKey)
169+
.header("Content-Type", "application/json")
170+
.header("Accept", "application/json")
171+
.header("User-Agent", USER_AGENT)
172+
.method("PATCH", HttpRequest.BodyPublishers.ofString(jsonBody))
173+
.build();
174+
175+
return execute(request, responseType);
176+
}
177+
126178
/**
127179
* Perform a DELETE request.
128180
*
129181
* @param path API path (e.g. "/domains/example.com")
130182
* @throws LettrException on error
131183
*/
132184
public void delete(String path) throws LettrException {
133-
delete(path, null);
185+
delete(path, (Map<String, String>) null);
134186
}
135187

136188
/**
@@ -152,6 +204,38 @@ public void delete(String path, Map<String, String> queryParams) throws LettrExc
152204
.DELETE()
153205
.build();
154206

207+
executeNoResponse(request);
208+
}
209+
210+
/**
211+
* Perform a DELETE request with a JSON body, returning a deserialized response.
212+
* Used by bulk delete endpoints that report counts.
213+
*
214+
* @param path API path
215+
* @param body request body object (will be serialized to JSON)
216+
* @param responseType the type to deserialize the "data" field into
217+
* @param <T> response data type
218+
* @return deserialized response data
219+
* @throws LettrException on error
220+
*/
221+
public <T> T delete(String path, Object body, Type responseType) throws LettrException {
222+
String url = buildUrl(path, null);
223+
String jsonBody = gson.toJson(body);
224+
225+
HttpRequest request = HttpRequest.newBuilder()
226+
.uri(URI.create(url))
227+
.timeout(DEFAULT_TIMEOUT)
228+
.header("Authorization", "Bearer " + apiKey)
229+
.header("Content-Type", "application/json")
230+
.header("Accept", "application/json")
231+
.header("User-Agent", USER_AGENT)
232+
.method("DELETE", HttpRequest.BodyPublishers.ofString(jsonBody))
233+
.build();
234+
235+
return execute(request, responseType);
236+
}
237+
238+
private void executeNoResponse(HttpRequest request) throws LettrException {
155239
try {
156240
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
157241
int statusCode = response.statusCode();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.lettr.services.audience;
2+
3+
import com.lettr.services.audience.contacts.AudienceContacts;
4+
import com.lettr.services.audience.lists.AudienceLists;
5+
import com.lettr.services.audience.properties.AudienceProperties;
6+
import com.lettr.services.audience.segments.AudienceSegments;
7+
import com.lettr.services.audience.topics.AudienceTopics;
8+
9+
import javax.annotation.Nonnull;
10+
11+
/**
12+
* Entry point for the Lettr audience API. Returns sub-services for managing
13+
* audience lists, contacts, topics, properties, and segments.
14+
*/
15+
public class Audience {
16+
17+
private final String apiKey;
18+
19+
public Audience(@Nonnull String apiKey) {
20+
this.apiKey = apiKey;
21+
}
22+
23+
/** Returns the audience lists service. */
24+
@Nonnull
25+
public AudienceLists lists() {
26+
return new AudienceLists(apiKey);
27+
}
28+
29+
/** Returns the audience contacts service. */
30+
@Nonnull
31+
public AudienceContacts contacts() {
32+
return new AudienceContacts(apiKey);
33+
}
34+
35+
/** Returns the audience topics service. */
36+
@Nonnull
37+
public AudienceTopics topics() {
38+
return new AudienceTopics(apiKey);
39+
}
40+
41+
/** Returns the audience properties service. */
42+
@Nonnull
43+
public AudienceProperties properties() {
44+
return new AudienceProperties(apiKey);
45+
}
46+
47+
/** Returns the audience segments service. */
48+
@Nonnull
49+
public AudienceSegments segments() {
50+
return new AudienceSegments(apiKey);
51+
}
52+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package com.lettr.services.audience.contacts;
2+
3+
import com.lettr.core.exception.LettrException;
4+
import com.lettr.services.BaseService;
5+
import com.lettr.services.audience.contacts.model.AudienceContactView;
6+
import com.lettr.services.audience.contacts.model.BulkAttachContactsResponse;
7+
import com.lettr.services.audience.contacts.model.BulkContactListsOptions;
8+
import com.lettr.services.audience.contacts.model.BulkCreateAudienceContactsOptions;
9+
import com.lettr.services.audience.contacts.model.BulkCreateAudienceContactsResponse;
10+
import com.lettr.services.audience.contacts.model.BulkDetachContactsResponse;
11+
import com.lettr.services.audience.contacts.model.CreateAudienceContactOptions;
12+
import com.lettr.services.audience.contacts.model.ListAudienceContactsParams;
13+
import com.lettr.services.audience.contacts.model.ListAudienceContactsResponse;
14+
import com.lettr.services.audience.contacts.model.UpdateAudienceContactOptions;
15+
16+
import javax.annotation.Nonnull;
17+
import javax.annotation.Nullable;
18+
19+
/**
20+
* Service for managing audience contacts and their list/topic memberships.
21+
*/
22+
public class AudienceContacts extends BaseService {
23+
24+
public AudienceContacts(@Nonnull String apiKey) {
25+
super(apiKey);
26+
}
27+
28+
/** List contacts with default pagination. */
29+
@Nonnull
30+
public ListAudienceContactsResponse list() throws LettrException {
31+
return list(null);
32+
}
33+
34+
/** List contacts with optional pagination and filters. */
35+
@Nonnull
36+
public ListAudienceContactsResponse list(@Nullable ListAudienceContactsParams params) throws LettrException {
37+
return httpClient.get("/audience/contacts",
38+
params != null ? params.toQueryParams() : null,
39+
ListAudienceContactsResponse.class);
40+
}
41+
42+
/** Retrieve a single contact. */
43+
@Nonnull
44+
public AudienceContactView get(@Nonnull String contactId) throws LettrException {
45+
if (contactId == null || contactId.isEmpty()) {
46+
throw new IllegalArgumentException("contactId is required");
47+
}
48+
return httpClient.get("/audience/contacts/" + contactId, null, AudienceContactView.class);
49+
}
50+
51+
/** Create a single contact (optionally with double opt-in). */
52+
@Nonnull
53+
public AudienceContactView create(@Nonnull CreateAudienceContactOptions options) throws LettrException {
54+
if (options == null) {
55+
throw new IllegalArgumentException("options is required");
56+
}
57+
return httpClient.post("/audience/contacts", options, AudienceContactView.class);
58+
}
59+
60+
/** Bulk create up to 1000 contacts. */
61+
@Nonnull
62+
public BulkCreateAudienceContactsResponse bulkCreate(@Nonnull BulkCreateAudienceContactsOptions options) throws LettrException {
63+
if (options == null) {
64+
throw new IllegalArgumentException("options is required");
65+
}
66+
return httpClient.post("/audience/contacts/bulk", options, BulkCreateAudienceContactsResponse.class);
67+
}
68+
69+
/** Partially update a contact. */
70+
@Nonnull
71+
public AudienceContactView update(@Nonnull String contactId, @Nonnull UpdateAudienceContactOptions options) throws LettrException {
72+
if (contactId == null || contactId.isEmpty()) {
73+
throw new IllegalArgumentException("contactId is required");
74+
}
75+
if (options == null) {
76+
throw new IllegalArgumentException("options is required");
77+
}
78+
return httpClient.patch("/audience/contacts/" + contactId, options, AudienceContactView.class);
79+
}
80+
81+
/** Permanently delete a contact. */
82+
public void delete(@Nonnull String contactId) throws LettrException {
83+
if (contactId == null || contactId.isEmpty()) {
84+
throw new IllegalArgumentException("contactId is required");
85+
}
86+
httpClient.delete("/audience/contacts/" + contactId);
87+
}
88+
89+
/** Bulk attach contacts to lists (cartesian product of contactIds × listIds). */
90+
@Nonnull
91+
public BulkAttachContactsResponse bulkAttachToLists(@Nonnull BulkContactListsOptions options) throws LettrException {
92+
if (options == null) {
93+
throw new IllegalArgumentException("options is required");
94+
}
95+
return httpClient.post("/audience/contacts/lists/bulk", options, BulkAttachContactsResponse.class);
96+
}
97+
98+
/** Bulk detach contacts from lists (cartesian product of contactIds × listIds). */
99+
@Nonnull
100+
public BulkDetachContactsResponse bulkDetachFromLists(@Nonnull BulkContactListsOptions options) throws LettrException {
101+
if (options == null) {
102+
throw new IllegalArgumentException("options is required");
103+
}
104+
return httpClient.delete("/audience/contacts/lists/bulk", options, BulkDetachContactsResponse.class);
105+
}
106+
107+
/** Attach a single contact to a single list. Idempotent. */
108+
public void attachToList(@Nonnull String contactId, @Nonnull String listId) throws LettrException {
109+
if (contactId == null || contactId.isEmpty()) {
110+
throw new IllegalArgumentException("contactId is required");
111+
}
112+
if (listId == null || listId.isEmpty()) {
113+
throw new IllegalArgumentException("listId is required");
114+
}
115+
httpClient.post("/audience/contacts/" + contactId + "/lists/" + listId, null);
116+
}
117+
118+
/** Detach a single contact from a single list. Idempotent. */
119+
public void detachFromList(@Nonnull String contactId, @Nonnull String listId) throws LettrException {
120+
if (contactId == null || contactId.isEmpty()) {
121+
throw new IllegalArgumentException("contactId is required");
122+
}
123+
if (listId == null || listId.isEmpty()) {
124+
throw new IllegalArgumentException("listId is required");
125+
}
126+
httpClient.delete("/audience/contacts/" + contactId + "/lists/" + listId);
127+
}
128+
129+
/** Subscribe a contact to a topic. Idempotent. */
130+
public void subscribeToTopic(@Nonnull String contactId, @Nonnull String topicId) throws LettrException {
131+
if (contactId == null || contactId.isEmpty()) {
132+
throw new IllegalArgumentException("contactId is required");
133+
}
134+
if (topicId == null || topicId.isEmpty()) {
135+
throw new IllegalArgumentException("topicId is required");
136+
}
137+
httpClient.post("/audience/contacts/" + contactId + "/topics/" + topicId, null);
138+
}
139+
140+
/** Unsubscribe a contact from a topic. Idempotent. */
141+
public void unsubscribeFromTopic(@Nonnull String contactId, @Nonnull String topicId) throws LettrException {
142+
if (contactId == null || contactId.isEmpty()) {
143+
throw new IllegalArgumentException("contactId is required");
144+
}
145+
if (topicId == null || topicId.isEmpty()) {
146+
throw new IllegalArgumentException("topicId is required");
147+
}
148+
httpClient.delete("/audience/contacts/" + contactId + "/topics/" + topicId);
149+
}
150+
}

0 commit comments

Comments
 (0)