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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.6.0] - 2026-09-10

Brings this client level with lettr-php: template modules, the folders endpoint, preparation status, and idempotent sends. Everything is additive - code written against 1.5.1 keeps compiling and sends identical requests.

### Added

- **`lettr.folders().list()`** - the folders templates are filed into, each with its `purpose` and `templatesCount`. This is what `CreateTemplateOptions.folderId()` was missing: nothing else returned a folder id, so a caller either omitted it and accepted whichever folder the API picked, or hardcoded an integer read out of an app URL. Read-only, because deleting a folder moves or deletes the templates inside it.
- **`TemplatePurpose`** (`TRANSACTIONAL`, `CAMPAIGN`) on `CreateTemplateOptions`, on every template response, and as a `ListTemplatesParams` filter.
- **`TemplatePreparationStatus`** (`PENDING`, `READY`, `FAILED`) on every template response, with `isSettled()`.

`isSettled()` rather than `isReady()` on purpose: it answers "is what I sent what will go out", which is not the same question as "can I send this". After an *update* the previous render stays in place, so a pending template is still sendable - it is serving the old content.

Both getters default when the API omits the field - `TRANSACTIONAL` and `READY` - because on a deployment that predates them every template with HTML was simply usable. Defaulting to `PENDING` would make an older API look like a stalled queue.
- **`ListTemplatesParams.folderId()`** - one `perPage(100)` call reconciles a whole bulk import instead of a detail call per template, each dragging the full HTML payload against the same rate limit. A folder outside the resolved project is a 404, not an empty list, so a typo cannot be misread as "nothing is there yet".
- **`emails().send(options, idempotencyKey)`** - a second overload, so existing single-argument calls are untouched. Reuse the key when you retry and the API returns the original result instead of delivering a second email; `CreateEmailResponse.isReplayed()` says when that happened.

You choose the key; the SDK never generates one. It only works if both attempts use the same value, and the SDK does not retry - one `send()` is one HTTP request - so the retry is yours. A malformed key throws `IllegalArgumentException` **before any request goes out**; `IdempotencyKeys.isValid()` is public for callers deriving keys from their own ids.
- **`IdempotencyInProgressException`** and **`IdempotencyConflictException`**, both extending `LettrApiException` so existing handlers keep working. The first carries `getRetryAfter()` and must be retried with the *same* key; the second means that key was used with a different payload and will fail identically forever.

### Notes

- Keys are scoped per team **and** API key, so the same string through a different API key is a different key. The provider retains one for 24 hours.
- `HttpClient` gained `postWithHeaders()` and a small `ApiResponse<T>` holder for the one place a response header carries meaning. The existing `post()` / `get()` / `put()` methods are unchanged.

## [1.5.1] - 2026-08-15

### Fixed
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.5.1
VERSION=1.6.0
POM_ARTIFACT_ID=lettr-java
POM_NAME=Lettr Java SDK
POM_DESCRIPTION=Java SDK for the Lettr Email API
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/lettr/Lettr.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.lettr.services.campaigns.Campaigns;
import com.lettr.services.domains.Domains;
import com.lettr.services.emails.Emails;
import com.lettr.services.folders.Folders;
import com.lettr.services.projects.Projects;
import com.lettr.services.system.System;
import com.lettr.services.templates.Templates;
Expand Down Expand Up @@ -63,6 +64,11 @@ public Lettr(@Nonnull String apiKey) {
/** Returns the Projects service for listing projects. */
@Nonnull public Projects projects() { return new Projects(apiKey); }

/**
* Returns the Folders service — where a usable {@code folderId} comes from.
*/
@Nonnull public Folders folders() { return new Folders(apiKey); }

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.lettr.core.exception;

/**
* The {@code Idempotency-Key} was already used with a <i>different</i> request
* payload (HTTP 409, {@code idempotency_key_conflict}).
*
* <p><b>Never retry this.</b> Two different emails were sent under one key,
* which is a bug on the caller's side; the same request will fail identically
* forever. Use a key that is unique per logical send, or send the payload the
* key was first used with.
*
* <p>Keys are scoped per team <b>and</b> API key, so the same string sent
* through a different API key is a different key and will not collide.
*
* <p>Extends {@link LettrApiException}, so existing
* {@code catch (LettrApiException)} handlers keep working unchanged.
*/
public class IdempotencyConflictException extends LettrApiException {

public IdempotencyConflictException(String message, int statusCode, String errorCode) {
super(message, statusCode, errorCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.lettr.core.exception;

/**
* The original request for this {@code Idempotency-Key} is still being
* processed (HTTP 409, {@code idempotency_in_progress}).
*
* <p>Unlike {@link IdempotencyConflictException} this one <b>is</b> retryable,
* and must be retried with the <i>same</i> key — a fresh key would send a
* second email. Wait {@link #getRetryAfter()} seconds first.
*/
public class IdempotencyInProgressException extends LettrApiException {

private final Integer retryAfter;

public IdempotencyInProgressException(String message, int statusCode, String errorCode, Integer retryAfter) {
super(message, statusCode, errorCode);
this.retryAfter = retryAfter;
}

/**
* Seconds to wait before retrying, from the {@code Retry-After} header.
*
* @return the wait in seconds, or null when the API did not send one
*/
public Integer getRetryAfter() {
return retryAfter;
}
}
107 changes: 101 additions & 6 deletions src/main/java/com/lettr/core/net/HttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.reflect.TypeToken;
import com.lettr.core.exception.IdempotencyConflictException;
import com.lettr.core.exception.IdempotencyInProgressException;
import com.lettr.core.exception.LettrApiException;
import com.lettr.core.exception.LettrException;
import com.lettr.core.exception.LettrValidationException;
Expand Down Expand Up @@ -99,6 +101,70 @@ public <T> T post(String path, Object body, Type responseType) throws LettrExcep
return execute(request, responseType);
}

/**
* Perform a POST request with a JSON body and extra request headers,
* returning both the deserialized data and the response headers.
*
* <p>Separate from {@link #post(String, Object, Type)} rather than an
* overload with nullable arguments, because only one endpoint needs it: the
* {@code Idempotency-Replayed} response header on a send. Returning the
* headers rather than storing them keeps concurrent calls from reading each
* other's.
*
* @param path API path
* @param body request body object
* @param responseType the type to deserialize the "data" field into
* @param extraHeaders headers merged over the defaults, or null
* @param <T> response data type
* @return the deserialized data alongside the response headers
* @throws LettrException on error
*/
public <T> ApiResponse<T> postWithHeaders(String path, Object body, Type responseType,
Map<String, String> extraHeaders) throws LettrException {
String url = buildUrl(path, null);
String jsonBody = gson.toJson(body);

HttpRequest.Builder builder = 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);

if (extraHeaders != null) {
for (Map.Entry<String, String> entry : extraHeaders.entrySet()) {
builder.header(entry.getKey(), entry.getValue());
}
}

return executeWithHeaders(
builder.POST(HttpRequest.BodyPublishers.ofString(jsonBody)).build(),
responseType);
}

/**
* A deserialized response together with the headers it arrived with.
*
* @param <T> the deserialized data type
*/
public static final class ApiResponse<T> {
private final T data;
private final java.net.http.HttpHeaders headers;

ApiResponse(T data, java.net.http.HttpHeaders headers) {
this.data = data;
this.headers = headers;
}

public T getData() { return data; }

/** The first value of a response header, or null when absent. */
public String header(String name) {
return headers.firstValue(name).orElse(null);
}
}

/**
* Perform a POST request with no body, returning a deserialized response.
* Used by endpoints whose only input is the path parameter (e.g.
Expand Down Expand Up @@ -291,7 +357,7 @@ private void executeNoResponse(HttpRequest request) throws LettrException {
}

if (statusCode >= 400) {
handleErrorResponse(statusCode, response.body());
handleErrorResponse(statusCode, response.body(), response.headers());
}
} catch (LettrException e) {
throw e;
Expand All @@ -304,26 +370,30 @@ private void executeNoResponse(HttpRequest request) throws LettrException {
}

private <T> T execute(HttpRequest request, Type responseType) throws LettrException {
return this.<T>executeWithHeaders(request, responseType).getData();
}

private <T> ApiResponse<T> executeWithHeaders(HttpRequest request, Type responseType) throws LettrException {
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
int statusCode = response.statusCode();
String responseBody = response.body();

if (statusCode >= 400) {
handleErrorResponse(statusCode, responseBody);
handleErrorResponse(statusCode, responseBody, response.headers());
}

if (responseBody == null || responseBody.isEmpty()) {
return null;
return new ApiResponse<>(null, response.headers());
}

JsonObject json = JsonParser.parseString(responseBody).getAsJsonObject();

if (json.has("data")) {
return gson.fromJson(json.get("data"), responseType);
return new ApiResponse<>(gson.fromJson(json.get("data"), responseType), response.headers());
}

return gson.fromJson(responseBody, responseType);
return new ApiResponse<>(gson.fromJson(responseBody, responseType), response.headers());
} catch (LettrException e) {
throw e;
} catch (IOException e) {
Expand All @@ -336,7 +406,8 @@ private <T> T execute(HttpRequest request, Type responseType) throws LettrExcept
}
}

private void handleErrorResponse(int statusCode, String responseBody) throws LettrException {
private void handleErrorResponse(int statusCode, String responseBody,
java.net.http.HttpHeaders headers) throws LettrException {
if (responseBody == null || responseBody.isEmpty()) {
throw new LettrApiException("API request failed with status " + statusCode, statusCode, null);
}
Expand All @@ -362,6 +433,16 @@ private void handleErrorResponse(int statusCode, String responseBody) throws Let
throw new LettrValidationException(message, errors);
}

// The two idempotency conflicts need telling apart: one is safe to
// retry with the same key, the other will fail forever.
if (statusCode == 409 && "idempotency_in_progress".equals(errorCode)) {
throw new IdempotencyInProgressException(message, statusCode, errorCode, retryAfter(headers));
}

if (statusCode == 409 && "idempotency_key_conflict".equals(errorCode)) {
throw new IdempotencyConflictException(message, statusCode, errorCode);
}

throw new LettrApiException(message, statusCode, errorCode);
} catch (LettrException e) {
throw e;
Expand All @@ -370,6 +451,20 @@ private void handleErrorResponse(int statusCode, String responseBody) throws Let
}
}

/** {@code Retry-After} in seconds, or null when absent or unparseable. */
private Integer retryAfter(java.net.http.HttpHeaders headers) {
if (headers == null) {
return null;
}

try {
int seconds = Integer.parseInt(headers.firstValue("Retry-After").orElse(""));
return seconds > 0 ? seconds : null;
} catch (NumberFormatException e) {
return null;
}
}

private String buildUrl(String path, Map<String, String> queryParams) {
StringBuilder url = new StringBuilder(BASE_URL).append(path);

Expand Down
48 changes: 48 additions & 0 deletions src/main/java/com/lettr/core/util/IdempotencyKeys.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.lettr.core.util;

import java.util.regex.Pattern;

/**
* Validation for {@code Idempotency-Key} values.
*
* <p>The key identifies one logical send. Reuse it when you retry and the API
* returns the original result instead of delivering a second email.
*
* <p><b>You choose the key; the SDK never generates one.</b> It only works if
* both attempts use the same value, and the SDK does not retry — one
* {@code send()} is one HTTP request — so the retry is yours, and only you know
* that two calls are the same logical send. A key generated inside
* {@code send()} would differ on every attempt and protect nothing while
* looking like it did.
*/
public final class IdempotencyKeys {

/** The format the API accepts: 1–255 characters of letters, digits, {@code . _ -}. */
private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9._-]{1,255}$");

private IdempotencyKeys() {}

/**
* Whether a string is a usable idempotency key.
*
* <p>Exported so callers deriving keys from their own ids — an order
* number, a job id — can check before sending rather than discovering it as
* a 422.
*/
public static boolean isValid(String key) {
return key != null && PATTERN.matcher(key).matches();
}

/**
* Throws {@link IllegalArgumentException} if the key is malformed.
*
* <p>Checked locally so a bad key fails on your machine instead of costing
* a round trip and a 422.
*/
public static void validate(String key) {
if (!isValid(key)) {
throw new IllegalArgumentException(
"Invalid idempotency key: use 1 to 255 letters, digits, periods, underscores or hyphens");
}
}
}
42 changes: 42 additions & 0 deletions src/main/java/com/lettr/services/emails/Emails.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.lettr.core.exception.LettrException;
import com.lettr.core.net.HttpClient;
import com.lettr.core.util.IdempotencyKeys;
import com.lettr.core.net.HttpClient;
import com.lettr.services.BaseService;
import com.lettr.services.emails.model.*;

Expand Down Expand Up @@ -31,6 +33,46 @@ public CreateEmailResponse send(@Nonnull CreateEmailOptions options) throws Lett
return httpClient.post("/emails", options, CreateEmailResponse.class);
}

/**
* Send an email under an idempotency key.
*
* <p>Reuse the key when you retry and the API returns the original result
* instead of delivering a second email;
* {@link CreateEmailResponse#isReplayed()} says when that happened.
*
* <p><b>You choose the key; the SDK never generates one.</b> It only works
* if both attempts use the same value, and the SDK does not retry — one
* {@code send()} is one HTTP request — so the retry is yours, and only you
* know that two calls are the same logical send.
*
* @param options the email to send
* @param idempotencyKey 1–255 characters of {@code [A-Za-z0-9._-]}
* @throws IllegalArgumentException if the key is malformed — thrown before
* any request is made
* @throws com.lettr.core.exception.IdempotencyInProgressException if the
* original send is still running; retry with the <b>same</b> key
* @throws com.lettr.core.exception.IdempotencyConflictException if the key
* was used with a different payload; do not retry
* @throws LettrException if the request fails
*/
@Nonnull
public CreateEmailResponse send(@Nonnull CreateEmailOptions options,
@Nonnull String idempotencyKey) throws LettrException {
// Checked here so a malformed key fails locally instead of costing a
// round trip and a 422.
IdempotencyKeys.validate(idempotencyKey);

HttpClient.ApiResponse<CreateEmailResponse> response = httpClient.postWithHeaders(
"/emails",
options,
CreateEmailResponse.class,
Map.of("Idempotency-Key", idempotencyKey));

CreateEmailResponse data = response.getData();
data.setReplayed("true".equalsIgnoreCase(response.header("Idempotency-Replayed")));
return data;
}

/**
* List sent emails with optional filtering and pagination.
*
Expand Down
Loading
Loading