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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ build/
.project
.classpath
.cursor/
bin/

# OS
.DS_Store
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ 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).

## [1.2.0] - 2026-05-25

### Added

- **Audience** namespace covering 33 endpoints across five sub-services, reached via `lettr.audience()`:
- `lists()` — list, get, create, update, delete, bulk delete
- `contacts()` — list, get, create (with optional double opt-in), bulk create, update, delete, attach/detach to lists (single + bulk), subscribe/unsubscribe to topics
- `topics()` — list, get, create, update, delete
- `properties()` — list, get, create, update, delete
- `segments()` — list, get, create, update, delete (with full `SegmentOperator` / `SegmentCondition` / `SegmentConditionGroup` modelling)
- `HttpClient.patch(path, body, type)` — used by every audience update endpoint
- `HttpClient.delete(path, body, type)` — used by `/audience/lists/bulk` and `/audience/contacts/lists/bulk`
- `HttpClient.post(path, body)` — void overload for attach/subscribe endpoints that return only `{message}`
- `NullablePropertiesAdapter` — preserves `null` map values when serializing `UpdateAudienceContactOptions.properties`, so the spec's "set a property to null to delete it" semantics actually work
- `UpdateAudiencePropertyOptionsAdapter` — always emits `fallback_value`, so `UpdateAudiencePropertyOptions.withFallbackValue(null)` clears the fallback instead of being silently dropped
- 46 unit tests covering the new audience namespace (Gson deserialization + argument validation)

### Notes

- `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
- `/audience/confirm/{token}` is intentionally not covered (token-flow endpoint)

## [1.1.0] - 2026-04-22

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The official Java SDK for the [Lettr](https://lettr.com) Email API. Send transac
### Gradle

```groovy
implementation 'com.lettr:lettr-java:1.0.0'
implementation 'com.lettr:lettr-java:1.2.0'
```

### Maven
Expand All @@ -16,7 +16,7 @@ implementation 'com.lettr:lettr-java:1.0.0'
<dependency>
<groupId>com.lettr</groupId>
<artifactId>lettr-java</artifactId>
<version>1.0.0</version>
<version>1.2.0</version>
</dependency>
```

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
GROUP=com.lettr
VERSION=1.1.0
VERSION=1.2.0
POM_ARTIFACT_ID=lettr-java
POM_NAME=Lettr Java SDK
POM_DESCRIPTION=Java SDK for the Lettr Email API
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/lettr/Lettr.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.lettr;

import com.lettr.services.audience.Audience;
import com.lettr.services.domains.Domains;
import com.lettr.services.emails.Emails;
import com.lettr.services.projects.Projects;
Expand Down Expand Up @@ -63,4 +64,7 @@ public Lettr(@Nonnull String apiKey) {

/** Returns the System service for health checks and API key validation. */
@Nonnull public System system() { return new System(apiKey); }

/** Returns the Audience namespace for managing lists, contacts, topics, properties, and segments. */
@Nonnull public Audience audience() { return new Audience(apiKey); }
}
88 changes: 86 additions & 2 deletions src/main/java/com/lettr/core/net/HttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
public class HttpClient {

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

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

/**
* Perform a POST request without expecting a deserialized response body.
* Useful for endpoints that return only a {@code {"message": "..."}} envelope.
*
* @param path API path
* @param body request body object (will be serialized to JSON, or {@code null} for no body)
* @throws LettrException on error
*/
public void post(String path, Object body) throws LettrException {
String url = buildUrl(path, null);
String jsonBody = gson.toJson(body);

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(DEFAULT_TIMEOUT)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("User-Agent", USER_AGENT)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();

executeNoResponse(request);
}

/**
* Perform a PATCH request with a JSON body.
*
* @param path API path
* @param body request body object (will be serialized to JSON)
* @param responseType the type to deserialize the "data" field into
* @param <T> response data type
* @return deserialized response data
* @throws LettrException on error
*/
public <T> T patch(String path, Object body, Type responseType) throws LettrException {
String url = buildUrl(path, null);
String jsonBody = gson.toJson(body);

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(DEFAULT_TIMEOUT)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("User-Agent", USER_AGENT)
.method("PATCH", HttpRequest.BodyPublishers.ofString(jsonBody))
.build();

return execute(request, responseType);
}

/**
* Perform a DELETE request.
*
* @param path API path (e.g. "/domains/example.com")
* @throws LettrException on error
*/
public void delete(String path) throws LettrException {
delete(path, null);
delete(path, (Map<String, String>) null);
}

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

executeNoResponse(request);
}

/**
* Perform a DELETE request with a JSON body, returning a deserialized response.
* Used by bulk delete endpoints that report counts.
*
* @param path API path
* @param body request body object (will be serialized to JSON)
* @param responseType the type to deserialize the "data" field into
* @param <T> response data type
* @return deserialized response data
* @throws LettrException on error
*/
public <T> T delete(String path, Object body, Type responseType) throws LettrException {
String url = buildUrl(path, null);
String jsonBody = gson.toJson(body);

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(DEFAULT_TIMEOUT)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("User-Agent", USER_AGENT)
.method("DELETE", HttpRequest.BodyPublishers.ofString(jsonBody))
.build();

return execute(request, responseType);
}

private void executeNoResponse(HttpRequest request) throws LettrException {
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
int statusCode = response.statusCode();
Expand Down
52 changes: 52 additions & 0 deletions src/main/java/com/lettr/services/audience/Audience.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.lettr.services.audience;

import com.lettr.services.audience.contacts.AudienceContacts;
import com.lettr.services.audience.lists.AudienceLists;
import com.lettr.services.audience.properties.AudienceProperties;
import com.lettr.services.audience.segments.AudienceSegments;
import com.lettr.services.audience.topics.AudienceTopics;

import javax.annotation.Nonnull;

/**
* Entry point for the Lettr audience API. Returns sub-services for managing
* audience lists, contacts, topics, properties, and segments.
*/
public class Audience {

private final String apiKey;

public Audience(@Nonnull String apiKey) {
this.apiKey = apiKey;
}

/** Returns the audience lists service. */
@Nonnull
public AudienceLists lists() {
return new AudienceLists(apiKey);
}

/** Returns the audience contacts service. */
@Nonnull
public AudienceContacts contacts() {
return new AudienceContacts(apiKey);
}

/** Returns the audience topics service. */
@Nonnull
public AudienceTopics topics() {
return new AudienceTopics(apiKey);
}

/** Returns the audience properties service. */
@Nonnull
public AudienceProperties properties() {
return new AudienceProperties(apiKey);
}

/** Returns the audience segments service. */
@Nonnull
public AudienceSegments segments() {
return new AudienceSegments(apiKey);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.lettr.services.audience.contacts;

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.BulkCreateAudienceContactsOptions;
import com.lettr.services.audience.contacts.model.BulkCreateAudienceContactsResponse;
import com.lettr.services.audience.contacts.model.BulkDetachContactsResponse;
import com.lettr.services.audience.contacts.model.CreateAudienceContactOptions;
import com.lettr.services.audience.contacts.model.ListAudienceContactsParams;
import com.lettr.services.audience.contacts.model.ListAudienceContactsResponse;
import com.lettr.services.audience.contacts.model.UpdateAudienceContactOptions;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

/**
* Service for managing audience contacts and their list/topic memberships.
*/
public class AudienceContacts extends BaseService {

public AudienceContacts(@Nonnull String apiKey) {
super(apiKey);
}

/** List contacts with default pagination. */
@Nonnull
public ListAudienceContactsResponse list() throws LettrException {
return list(null);
}

/** List contacts with optional pagination and filters. */
@Nonnull
public ListAudienceContactsResponse list(@Nullable ListAudienceContactsParams params) throws LettrException {
return httpClient.get("/audience/contacts",
params != null ? params.toQueryParams() : null,
ListAudienceContactsResponse.class);
}

/** Retrieve a single contact. */
@Nonnull
public AudienceContactView get(@Nonnull String contactId) throws LettrException {
if (contactId == null || contactId.isEmpty()) {
throw new IllegalArgumentException("contactId is required");
}
return httpClient.get("/audience/contacts/" + contactId, null, AudienceContactView.class);
}

/** Create a single contact (optionally with double opt-in). */
@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);
}

/** Bulk create up to 1000 contacts. */
@Nonnull
public BulkCreateAudienceContactsResponse bulkCreate(@Nonnull BulkCreateAudienceContactsOptions options) throws LettrException {
if (options == null) {
throw new IllegalArgumentException("options is required");
}
return httpClient.post("/audience/contacts/bulk", options, BulkCreateAudienceContactsResponse.class);
}

/** Partially update a contact. */
@Nonnull
public AudienceContactView update(@Nonnull String contactId, @Nonnull UpdateAudienceContactOptions options) throws LettrException {
if (contactId == null || contactId.isEmpty()) {
throw new IllegalArgumentException("contactId is required");
}
if (options == null) {
throw new IllegalArgumentException("options is required");
}
return httpClient.patch("/audience/contacts/" + contactId, options, AudienceContactView.class);
}

/** Permanently delete a contact. */
public void delete(@Nonnull String contactId) throws LettrException {
if (contactId == null || contactId.isEmpty()) {
throw new IllegalArgumentException("contactId is required");
}
httpClient.delete("/audience/contacts/" + contactId);
}

/** Bulk attach contacts to lists (cartesian product of contactIds × listIds). */
@Nonnull
public BulkAttachContactsResponse bulkAttachToLists(@Nonnull BulkContactListsOptions options) throws LettrException {
if (options == null) {
throw new IllegalArgumentException("options is required");
}
return httpClient.post("/audience/contacts/lists/bulk", options, BulkAttachContactsResponse.class);
}

/** Bulk detach contacts from lists (cartesian product of contactIds × listIds). */
@Nonnull
public BulkDetachContactsResponse bulkDetachFromLists(@Nonnull BulkContactListsOptions options) throws LettrException {
if (options == null) {
throw new IllegalArgumentException("options is required");
}
return httpClient.delete("/audience/contacts/lists/bulk", options, BulkDetachContactsResponse.class);
}

/** Attach a single contact to a single list. Idempotent. */
public void attachToList(@Nonnull String contactId, @Nonnull String listId) throws LettrException {
if (contactId == null || contactId.isEmpty()) {
throw new IllegalArgumentException("contactId is required");
}
if (listId == null || listId.isEmpty()) {
throw new IllegalArgumentException("listId is required");
}
httpClient.post("/audience/contacts/" + contactId + "/lists/" + listId, null);
}

/** Detach a single contact from a single list. Idempotent. */
public void detachFromList(@Nonnull String contactId, @Nonnull String listId) throws LettrException {
if (contactId == null || contactId.isEmpty()) {
throw new IllegalArgumentException("contactId is required");
}
if (listId == null || listId.isEmpty()) {
throw new IllegalArgumentException("listId is required");
}
httpClient.delete("/audience/contacts/" + contactId + "/lists/" + listId);
}

/** Subscribe a contact to a topic. Idempotent. */
public void subscribeToTopic(@Nonnull String contactId, @Nonnull String topicId) throws LettrException {
if (contactId == null || contactId.isEmpty()) {
throw new IllegalArgumentException("contactId is required");
}
if (topicId == null || topicId.isEmpty()) {
throw new IllegalArgumentException("topicId is required");
}
httpClient.post("/audience/contacts/" + contactId + "/topics/" + topicId, null);
}

/** Unsubscribe a contact from a topic. Idempotent. */
public void unsubscribeFromTopic(@Nonnull String contactId, @Nonnull String topicId) throws LettrException {
if (contactId == null || contactId.isEmpty()) {
throw new IllegalArgumentException("contactId is required");
}
if (topicId == null || topicId.isEmpty()) {
throw new IllegalArgumentException("topicId is required");
}
httpClient.delete("/audience/contacts/" + contactId + "/topics/" + topicId);
}
}
Loading
Loading