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
55 changes: 51 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ Upgrading from 2.x? Read [UPGRADE.md](UPGRADE.md).
<dependency>
<groupId>co.lettermint</groupId>
<artifactId>lettermint</artifactId>
<version>3.0.0</version>
<version>3.1.0</version>
</dependency>
```

### Gradle

```groovy
implementation 'co.lettermint:lettermint:3.0.0'
implementation 'co.lettermint:lettermint:3.1.0'
```

## Quick start
Expand Down Expand Up @@ -259,7 +259,7 @@ String html = lettermint.messages().html("message-id");
| `team().members()` | `list`, `iterate`, `retrieve`, `updateAssignment` |
| `webhooks()` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerateSecret` |
| `webhooks().deliveries()` | `list(webhookId)`, `iterate(webhookId)`, `retrieve(webhookId, deliveryId)` |
| (root) | `ping`, `analytics`, `blockedFileTypes` |
| (root) | `ping`, `analytics`, `analyticsPages`, `blockedFileTypes` |

Request and response types are immutable records in `co.lettermint.types`. Build requests with their builder (`UpdateRouteData.builder().name("Main").build()`) or, for small ones, the constructor (`new StoreDomainData("acme.com")`). In update requests, a field that is optional and nullable is an `OptionalNullable`: leave it unset to keep the current value, or pass `null` to the builder to clear it:

Expand Down Expand Up @@ -291,6 +291,53 @@ for (MessageListData message : lettermint.messages().iterate(ListMessagesQuery.b
lettermint.webhooks().deliveries().iterate(webhookId).stream().limit(100).forEach(System.out::println);
```

### Analytics

`lettermint.analytics(query)` runs one analytics query. `metrics` is the only required field; by default the API returns a summary of the last 30 days:

```java
AnalyticsResponse result = lettermint.analytics(AnalyticsQuery.builder()
.metrics(List.of(AnalyticsMetric.DELIVERED, AnalyticsMetric.BOUNCED, AnalyticsMetric.DELIVERY_RATE))
.from("2026-10-01")
.to("2026-10-31")
.timezone("Europe/Amsterdam")
.build());

System.out.println(result.data().summary().metrics().deliveryRate()); // 0.9836, or null when there is no data
System.out.println(result.meta().partial() + " " + result.meta().effectiveTo());
```

Add `include` to ask for a `time_series` or a `breakdown`. A breakdown needs `groupBy`, and the API returns its rows in pages of `limit` (at most 200). `analyticsPages()` follows `pagination().nextCursor()` for you. It returns a `CursorIterable` that yields one whole response per request, so each page keeps its `meta()` and `pagination()`:

```java
AnalyticsQuery query = AnalyticsQuery.builder()
.metrics(List.of(AnalyticsMetric.DELIVERED, AnalyticsMetric.BOUNCED))
.include(List.of(AnalyticsSection.BREAKDOWN))
.groupBy(List.of(AnalyticsGroupDimension.RECIPIENT_DOMAIN))
.sort(new AnalyticsSort(AnalyticsMetric.BOUNCED, AnalyticsSortDirection.DESC))
.limit(200)
.build();

List<AnalyticsBreakdownRow> rows = new ArrayList<>();
for (AnalyticsResponse page : lettermint.analyticsPages(query)) {
if (page.data().breakdown() != null) {
rows.addAll(page.data().breakdown());
}
if (page.pagination().truncated()) {
System.err.println("More groups exist than the API ranks.");
}
}
```

A cursor expires 60 seconds after its response, so read the next page promptly. An expired cursor throws a `ValidationException` with `getErrors().get("cursor")`; run the query again to start over.

A few things to know when you read a response:

- A metric is `null` when the API cannot measure it for that row or bucket, and a rate is `null` when its denominator is zero. `0` means a measured zero. A metric the query did not ask for is `null` as well.
- `data().summary()`, `data().timeSeries()` and `data().breakdown()` are present only when `include` asks for them, and `null` otherwise. `previous()`, `change()` and `meta().comparison()` are present only with `compare`.
- `smtp_response_group` can be used in `groupBy` but not as a filter dimension.
- Analytics can answer `503` or `504` when a query takes too long or the service is busy. Both throw a `ServerException`; see [Errors](#errors).

### Timeouts and cancellation

Every method takes an optional last argument with per-call options: `RequestOptions.timeout(Duration)`, or `SendOptions` for calls that also take an idempotency key.
Expand All @@ -316,7 +363,7 @@ Every exception the SDK throws is unchecked and extends `LettermintException`:
| `ConflictException` | 409 | |
| `ValidationException` | 422 | `getErrors` (field errors) |
| `RateLimitException` | 429 | `getRetryAfter` (`Duration`) |
| `ServerException` | 5xx | |
| `ServerException` | 5xx | `getRetryAfter` (`Duration`, when the API sent `Retry-After`) |
| `TimeoutException` | No complete response within the timeout | `getTimeout` |
| `ConnectionException` | The request failed (DNS, TLS, refused, reset) | `getCause` |
| `UnexpectedResponseException` | An empty or non-JSON body where JSON was expected, or an error page such as a proxy's HTML 502 | `getStatus`, `getBodyExcerpt` |
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/co/lettermint/CursorIterable.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
import java.util.stream.StreamSupport;

/**
* Every item of a cursor-paginated list, returned by the {@code iterate()} methods.
* Every item of a cursor-paginated list, returned by the {@code iterate()} methods, or every response of
* an analytics query, returned by {@code analyticsPages()}.
*
* <pre>{@code
* for (DomainListData domain : lettermint.domains().iterate()) { ... }
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/co/lettermint/Lettermint.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import co.lettermint.types.AnalyticsQuery;
import co.lettermint.types.AnalyticsResponse;
import co.lettermint.types.BlockedFileTypes;
import co.lettermint.types.CursorPage;
import co.lettermint.types.Operations;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.Collections;

/**
* The Lettermint client.
Expand Down Expand Up @@ -171,6 +173,43 @@ public AnalyticsResponse analytics(AnalyticsQuery query, RequestOptions options)
return transport.call(Operations.QUERY_ANALYTICS, Transport.call("analytics").body(query).options(options));
}

/**
* Queries email analytics and follows {@code pagination.next_cursor}, one whole response per
* request. Each response carries the next page of {@code data().breakdown()} with its own
* {@code meta()} and {@code pagination()}. Needs the team token.
*
* <p>A cursor expires 60 seconds after its response, so request the next page promptly; an expired
* cursor throws a {@link co.lettermint.exceptions.ValidationException}. The query passed in is not
* changed.
*
* @param query the metrics, dimensions and filters
* @return every response, requested one by one as you iterate
*/
public CursorIterable<AnalyticsResponse> analyticsPages(AnalyticsQuery query) {
return analyticsPages(query, null);
}

/**
* @param query the metrics, dimensions and filters
* @param options per-call options, applied to every request, or null
* @return every response, requested one by one as you iterate
* @see #analyticsPages(AnalyticsQuery)
*/
public CursorIterable<AnalyticsResponse> analyticsPages(AnalyticsQuery query, RequestOptions options) {
transport.assertAuth("analyticsPages", Operations.QUERY_ANALYTICS.auth());
String start = query == null ? null : query.cursor();
return new CursorIterable<>(cursor -> {
AnalyticsQuery body = query;
if (cursor != null) {
body = (query == null ? AnalyticsQuery.builder() : query.toBuilder()).cursor(cursor).build();
}
AnalyticsResponse page = transport.call(Operations.QUERY_ANALYTICS, Transport.call("analyticsPages").body(body).options(options));
String next = page == null || page.pagination() == null ? null : page.pagination().nextCursor();
// The cursor the query started from counts as used, like the ones CursorIterable has followed.
return new CursorPage<>(Collections.singletonList(page), null, null, next != null && next.equals(start) ? null : next, null, null, null);
});
}

/**
* The file extensions and MIME types that cannot be attached. Needs the team token.
*
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/co/lettermint/Transport.java
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,9 @@ static ApiException apiException(int status, JsonNode body, String retryAfter) {
case 429:
return new RateLimitException(status, message, code, details, decoded, parseRetryAfter(retryAfter));
default:
return status >= 500 ? new ServerException(status, message, code, details, decoded) : new ApiException(status, message, code, details, decoded);
return status >= 500
? new ServerException(status, message, code, details, decoded, parseRetryAfter(retryAfter))
: new ApiException(status, message, code, details, decoded);
}
}

Expand Down
25 changes: 25 additions & 0 deletions src/main/java/co/lettermint/exceptions/ServerException.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package co.lettermint.exceptions;

import java.time.Duration;

/** HTTP 5xx: a server error with a JSON or empty body. */
public class ServerException extends ApiException {
private static final long serialVersionUID = 1L;

private final Duration retryAfter;

/**
* @param status the HTTP status
* @param message the API's message
Expand All @@ -12,6 +16,27 @@ public class ServerException extends ApiException {
* @param body the decoded body, or null
*/
public ServerException(int status, String message, String code, Object details, Object body) {
this(status, message, code, details, body, null);
}

/**
* @param status the HTTP status
* @param message the API's message
* @param code the error code, or null
* @param details the error details, or null
* @param body the decoded body, or null
* @param retryAfter the wait from the {@code Retry-After} header, or null
*/
public ServerException(int status, String message, String code, Object details, Object body, Duration retryAfter) {
super(status, message, code, details, body);
this.retryAfter = retryAfter;
}

/**
* @return how long to wait, from the {@code Retry-After} header (seconds or an HTTP date), or null when the API
* sent none
*/
public Duration getRetryAfter() {
return retryAfter;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Generated by lettermint/sdk-generator — do not edit.
// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

package co.lettermint.types;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Generated by lettermint/sdk-generator — do not edit.
// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

package co.lettermint.types;
Expand Down Expand Up @@ -68,9 +68,6 @@ public final class AnalyticsCatalogueDimension {
/** {@code "failure_reason"} */
public static final AnalyticsCatalogueDimension FAILURE_REASON = new AnalyticsCatalogueDimension("failure_reason");

/** {@code "smtp_response_group"} */
public static final AnalyticsCatalogueDimension SMTP_RESPONSE_GROUP = new AnalyticsCatalogueDimension("smtp_response_group");

/** {@code "engagement_state"} */
public static final AnalyticsCatalogueDimension ENGAGEMENT_STATE = new AnalyticsCatalogueDimension("engagement_state");

Expand Down Expand Up @@ -114,7 +111,6 @@ public final class AnalyticsCatalogueDimension {
known.put(ENHANCED_STATUS_CATEGORY.value, ENHANCED_STATUS_CATEGORY);
known.put(BOUNCE_CLASSIFICATION.value, BOUNCE_CLASSIFICATION);
known.put(FAILURE_REASON.value, FAILURE_REASON);
known.put(SMTP_RESPONSE_GROUP.value, SMTP_RESPONSE_GROUP);
known.put(ENGAGEMENT_STATE.value, ENGAGEMENT_STATE);
known.put(REQUEST_ACTOR.value, REQUEST_ACTOR);
known.put(PROXY_TYPE.value, PROXY_TYPE);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Generated by lettermint/sdk-generator — do not edit.
// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

package co.lettermint.types;
Expand Down
6 changes: 3 additions & 3 deletions src/main/java/co/lettermint/types/AnalyticsComparison.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Generated by lettermint/sdk-generator — do not edit.
// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

package co.lettermint.types;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Generated by lettermint/sdk-generator — do not edit.
// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

package co.lettermint.types;
Expand Down
10 changes: 3 additions & 7 deletions src/main/java/co/lettermint/types/AnalyticsDimension.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Generated by lettermint/sdk-generator — do not edit.
// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

package co.lettermint.types;
Expand Down Expand Up @@ -72,9 +72,6 @@ public final class AnalyticsDimension {
/** {@code "failure_reason"} */
public static final AnalyticsDimension FAILURE_REASON = new AnalyticsDimension("failure_reason");

/** {@code "smtp_response_group"} */
public static final AnalyticsDimension SMTP_RESPONSE_GROUP = new AnalyticsDimension("smtp_response_group");

/** {@code "engagement_state"} */
public static final AnalyticsDimension ENGAGEMENT_STATE = new AnalyticsDimension("engagement_state");

Expand Down Expand Up @@ -118,7 +115,6 @@ public final class AnalyticsDimension {
known.put(ENHANCED_STATUS_CATEGORY.value, ENHANCED_STATUS_CATEGORY);
known.put(BOUNCE_CLASSIFICATION.value, BOUNCE_CLASSIFICATION);
known.put(FAILURE_REASON.value, FAILURE_REASON);
known.put(SMTP_RESPONSE_GROUP.value, SMTP_RESPONSE_GROUP);
known.put(ENGAGEMENT_STATE.value, ENGAGEMENT_STATE);
known.put(REQUEST_ACTOR.value, REQUEST_ACTOR);
known.put(PROXY_TYPE.value, PROXY_TYPE);
Expand Down
8 changes: 4 additions & 4 deletions src/main/java/co/lettermint/types/AnalyticsFilter.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Generated by lettermint/sdk-generator — do not edit.
// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

package co.lettermint.types;
Expand All @@ -17,7 +17,7 @@
*
* @param dimension Required.
* @param operator eq requires one value; in and not_in require one or more values; is_null and is_not_null require no values. Required.
* @param values String values to match. Supply one for eq, one or more for in/not_in, and none for null checks. Optional: null omits it.
* @param values String values to match. Supply one for eq, one or more for in/not_in, and none for null checks. project_id and route_id take UUIDs; smtp_code takes a three-digit reply code. Optional: null omits it.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Generated by lettermint/sdk-generator — do not edit.
// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

package co.lettermint.types;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Generated by lettermint/sdk-generator — do not edit.
// Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
// sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
// team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
// Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
// sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
// team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
// Naming profile: next

package co.lettermint.types;
Expand All @@ -16,7 +16,7 @@
import java.util.Objects;

/**
* A catalogue dimension or tag:&lt;case-sensitive name&gt; to group breakdown rows by. Engagement class dimensions (is_human, is_privacy, is_bot and is_scanner) can only filter.
* A catalogue dimension or tag:&lt;case-sensitive name&gt; to group breakdown rows by. Engagement class dimensions (is_human, is_privacy, is_bot and is_scanner) can only filter. smtp_response_group can only group: each row reads the SMTP reply of the group as its reply code, enhanced status code and response text, with addresses and other identifiers replaced by placeholders.
*
* An open enum: values that the API adds later decode as well and keep their raw value.
*/
Expand Down
Loading
Loading