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 @@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **A legacy read returned a `null` state.** Reading back an old numeric
provider transmission id is answered from delivery events, which report the
*provider's* vocabulary — a delivered email comes back `delivered`, which is
none of the five Lettr states. Gson deserializes an unrecognised enum value
to `null`, so `getState()` broke its own `@Nonnull` contract and handed the
caller an NPE waiting to happen. `ScheduledEmailState` now carries
`SUBMITTED`, `GENERATING`, `DELIVERED` and `BOUNCED` — deprecated, because
only that path can produce them — and anything it still does not recognise
reads as `UNKNOWN` rather than null, so a state the API adds later cannot
break reads again.

Scheduled emails were reworked server-side and this release follows that rework. Lettr used to hand a scheduled email straight to SparkPost, which made the provider's transmission the object you addressed; SparkPost retired per-transmission GET and DELETE, so Lettr now **owns the schedule** and only hands the email over when it is due. That moves the identity of a scheduled email, and this is the release where the client stops pretending otherwise.

### Changed

- **`ScheduledEmail.getState()` now returns `ScheduledEmailState`, not `String` — this will not compile against existing code.** Anything doing `"scheduled".equals(email.getState())` or `switch (email.getState())` over strings has to change to `email.getState() == ScheduledEmailState.SCHEDULED`. It is the one breaking change here, and it is deliberate: the old Javadoc listed the *provider's* states (`submitted`, `generating`, `delivered`, `bounced`, `unknown`), none of which a scheduled email reports any more. A string comparison against those values compiles perfectly and is silently always false — worse than a compile error, because it fails at the point where you decide whether an email still goes out. The five states are `SCHEDULED`, `SENDING`, `SENT`, `CANCELLED`, `FAILED`.

`ScheduledEmailState` carries `isCancellable()` (only `SCHEDULED` — from `SENDING` on, the provider has it and will not recall it) and `isTerminal()` (`SENT`, `CANCELLED`, `FAILED`, i.e. polling will never report anything new). `isCancellable()` is a local pre-check on a state you read at some earlier moment, not a guarantee: a cancel can still lose the race.
- **`emails().schedule()` returns `ScheduledEmail`, not `CreateEmailResponse`.** The API now answers the create with the whole scheduled email, so the `sch_` id, the state and the scheduled time come back in one call instead of costing a follow-up GET. `CreateEmailResponse`'s three fields are all still there under the same names, so `getRequestId()` / `getAccepted()` / `getRejected()` keep working; only a declared `CreateEmailResponse` variable type needs updating.
- **`emails().cancelScheduled()` returns the cancelled `ScheduledEmail` instead of `void`.** The endpoint changed from 204-no-content to 200 with a body, and the body is worth having: it is in state `CANCELLED` with `accepted` back to 0, which is the confirmation that nothing will be delivered. Existing calls that ignore the return value keep compiling.
- **`getScheduled()` and `cancelScheduled()` take `requestId`, not `transmissionId`.** A rename only — the parameter position and type are unchanged — but the *value* you pass is different, and that is the part to read twice. See below.

### Added

- **`ScheduledEmail.getRequestId()`** — Lettr's own id, prefixed `sch_`, and the id that addresses a scheduled email. This is what `schedule()` hands you and what `getScheduled()` / `cancelScheduled()` expect.
- **`ScheduledEmail.getTransmissionId()` is now `@Nullable`**, because it always could be and the annotation was a lie. It is the *provider's* id, it stays `null` until the email is actually sent, and it is the value that appears on **webhook events** — so it is what you correlate webhooks against, and `requestId` is not. Reading it while an email is still scheduled gets you `null`, by design, not by omission.

Passing an old numeric SparkPost transmission id to `getScheduled()` still works: the API answers it from delivery events, in a payload that carries no `request_id` at all. The SDK fills `requestId` in from the id you asked about, so `getRequestId()` is always the id that addresses the email you are holding, whichever era it came from.
- **`emails().listScheduled()`** and `listScheduled(ListScheduledEmailsParams)` — `GET /emails/scheduled`, which did not exist before. Offset-paginated (`ListScheduledEmailsResponse.getPagination()` is the shared `OffsetPagination`, *not* the cursor pagination `emails().list()` uses), filterable by `status(ScheduledEmailState)`, `perPage` 1–100 defaulting to 25, `page` 1-based. This is the only way to find a scheduled email whose `sch_` id you did not keep.
- **`ScheduledEmail.getAccepted()`, `getRejected()`, `getTag()`, `getFailureReason()`** — all four were already in the payload and silently dropped. `getFailureReason()` is populated only in the `FAILED` state; `getAccepted()` drops to 0 on cancel.

### Fixed

- `ScheduledEmail.getRecipients()` and `getEvents()` were annotated `@Nonnull` but returned the raw deserialized field, so they returned `null` whenever the API omitted the key — an NPE in the exact shape the annotation promised was impossible. Both now fall back to an empty list, matching `ListCampaignsResponse.getCampaigns()`.
- `ScheduledEmail.getSubject()` is `@Nullable`. The API returns `null` for a template-only send; the getter claimed otherwise.
- The scheduling window in `ScheduleEmailOptions`'s Javadoc said "within 3 days". It is **5 minutes to 30 days**; it has been 30 days since the rework, and the documented limit would have had callers rejecting valid sends client-side.
- `ScheduleEmailOptions`'s `scheduled_at` was the only model field in the SDK relying on its literal Java name to reach the wire instead of `@SerializedName`. It now carries the annotation (and the field is named `scheduledAt`), so an obfuscated or shrunk build cannot silently drop the schedule and send the email immediately.

### Notes

- `HttpClient` gained `delete(path, responseType)` for the one endpoint that answers a bodyless DELETE with content. The existing `delete(path)`, `delete(path, queryParams)` and `delete(path, body, responseType)` are unchanged; the new overload is unambiguous against them because a `Type` is never a `Map`.

## [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.
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/com/lettr/core/net/HttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,33 @@ public void delete(String path, Map<String, String> queryParams) throws LettrExc
executeNoResponse(request);
}

/**
* Perform a DELETE request with no body, returning a deserialized response.
* Used by endpoints that answer 200 with the resource they just removed
* (e.g. cancelling a scheduled email), so the caller can see its final state
* without a follow-up GET that would now 404.
*
* @param path API path
* @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, Type responseType) throws LettrException {
String url = buildUrl(path, null);

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

return execute(request, responseType);
}

/**
* Perform a DELETE request with a JSON body, returning a deserialized response.
* Used by bulk delete endpoints that report counts.
Expand Down
90 changes: 66 additions & 24 deletions src/main/java/com/lettr/services/emails/Emails.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import com.lettr.core.exception.LettrException;
import com.lettr.core.net.HttpClient;
import com.lettr.core.util.Args;
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 @@ -134,9 +134,7 @@ public GetEmailResponse get(@Nonnull String requestId) throws LettrException {
*/
@Nonnull
public GetEmailResponse get(@Nonnull String requestId, @Nullable String from, @Nullable String to) throws LettrException {
if (requestId == null || requestId.isEmpty()) {
throw new IllegalArgumentException("requestId is required");
}
Args.requireNonEmpty("requestId", requestId);
Map<String, String> params = null;
if (from != null || to != null) {
params = new LinkedHashMap<>();
Expand All @@ -149,42 +147,86 @@ public GetEmailResponse get(@Nonnull String requestId, @Nullable String from, @N
/**
* Schedule an email for future delivery.
*
* @param options schedule email options including {@code scheduledAt}
* @return response containing the request ID and acceptance counts
* @param options schedule email options including {@code scheduledAt}, which
* must be 5 minutes to 30 days in the future
* @return the scheduled email, including the {@code sch_} request ID that
* addresses it
* @throws LettrException if the request fails
*/
@Nonnull
public CreateEmailResponse schedule(@Nonnull ScheduleEmailOptions options) throws LettrException {
return httpClient.post("/emails/scheduled", options, CreateEmailResponse.class);
public ScheduledEmail schedule(@Nonnull ScheduleEmailOptions options) throws LettrException {
return httpClient.post("/emails/scheduled", options, ScheduledEmail.class);
}

/**
* Get details of a scheduled email transmission.
* List scheduled emails with optional filtering and pagination.
*
* @param transmissionId the transmission ID
* @return scheduled email details
* @param params optional query parameters; pass null for defaults
* @return a page of scheduled emails with pagination metadata
* @throws LettrException if the request fails
* @throws IllegalArgumentException if {@code transmissionId} is null or empty
*/
@Nonnull
public ScheduledEmail getScheduled(@Nonnull String transmissionId) throws LettrException {
if (transmissionId == null || transmissionId.isEmpty()) {
throw new IllegalArgumentException("transmissionId is required");
}
return httpClient.get("/emails/scheduled/" + HttpClient.encodePathSegment(transmissionId), null, ScheduledEmail.class);
public ListScheduledEmailsResponse listScheduled(@Nullable ListScheduledEmailsParams params) throws LettrException {
return httpClient.get("/emails/scheduled", params != null ? params.toQueryParams() : null,
ListScheduledEmailsResponse.class);
}

/** List scheduled emails with default pagination. */
@Nonnull
public ListScheduledEmailsResponse listScheduled() throws LettrException {
return listScheduled(null);
}

/**
* Cancel a scheduled email.
* Get a scheduled email.
*
* @param transmissionId the transmission ID to cancel
* @param requestId the {@code sch_} request ID from
* {@link #schedule(ScheduleEmailOptions)} — <b>not</b> the
* provider transmission ID that webhook events carry
* @return the scheduled email
* @throws LettrException if the request fails
* @throws IllegalArgumentException if {@code transmissionId} is null or empty
* @throws IllegalArgumentException if {@code requestId} is null or empty
*/
public void cancelScheduled(@Nonnull String transmissionId) throws LettrException {
if (transmissionId == null || transmissionId.isEmpty()) {
throw new IllegalArgumentException("transmissionId is required");
@Nonnull
public ScheduledEmail getScheduled(@Nonnull String requestId) throws LettrException {
Args.requireNonEmpty("requestId", requestId);
return withRequestId(
httpClient.get("/emails/scheduled/" + HttpClient.encodePathSegment(requestId), null, ScheduledEmail.class),
requestId);
}

/**
* Cancel a scheduled email. Only an email that has not yet been handed to
* the sending provider can be cancelled; see
* {@link ScheduledEmailState#isCancellable()}.
*
* @param requestId the {@code sch_} request ID to cancel — <b>not</b> the
* provider transmission ID that webhook events carry
* @return the cancelled email, in state
* {@link ScheduledEmailState#CANCELLED} and with {@code accepted} back to 0
* @throws LettrException if the request fails
* @throws IllegalArgumentException if {@code requestId} is null or empty
*/
@Nonnull
public ScheduledEmail cancelScheduled(@Nonnull String requestId) throws LettrException {
Args.requireNonEmpty("requestId", requestId);
return withRequestId(
httpClient.delete("/emails/scheduled/" + HttpClient.encodePathSegment(requestId), ScheduledEmail.class),
requestId);
}

/**
* Emails scheduled before Lettr owned the schedule are still addressed by
* their provider transmission ID, and the API answers those from delivery
* events in a payload that has no {@code request_id} at all. Filling it in
* keeps {@code getRequestId()} the id that addresses the email, whichever
* era it comes from.
*/
@Nonnull
private ScheduledEmail withRequestId(@Nonnull ScheduledEmail email, @Nonnull String requestId) {
if (email.getRequestId() == null) {
email.setRequestId(requestId);
}
httpClient.delete("/emails/scheduled/" + HttpClient.encodePathSegment(transmissionId));
return email;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.lettr.services.emails.model;

import com.lettr.core.model.PageParams;
import com.lettr.core.util.WireValues;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* Parameters for listing scheduled emails. All fields are optional. Composes
* {@link PageParams} for {@code page} / {@code per_page} and adds a
* {@code status} filter.
*/
public class ListScheduledEmailsParams {

private final PageParams pageParams;
private final ScheduledEmailState status;

private ListScheduledEmailsParams(Builder builder) {
this.pageParams = builder.pageParams.build();
this.status = builder.status;
}

@Nonnull
public static Builder builder() {
return new Builder();
}

@Nonnull
public Map<String, String> toQueryParams() {
Map<String, String> params = new LinkedHashMap<>(pageParams.toQueryParams());
if (status != null) params.put("status", WireValues.of(status));
return params;
}

public static class Builder {
private final PageParams.Builder pageParams = PageParams.builder();
private ScheduledEmailState status;

private Builder() {}

/** <b>(optional)</b> Page number (min 1, default 1). */
@Nonnull
public Builder page(@Nullable Integer page) {
pageParams.page(page);
return this;
}

/** <b>(optional)</b> Items per page (1–100, default 25). */
@Nonnull
public Builder perPage(@Nullable Integer perPage) {
pageParams.perPage(perPage);
return this;
}

/** <b>(optional)</b> Filters by lifecycle state. */
@Nonnull
public Builder status(@Nullable ScheduledEmailState status) {
this.status = status;
return this;
}

@Nonnull
public ListScheduledEmailsParams build() {
return new ListScheduledEmailsParams(this);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.lettr.services.emails.model;

import com.google.gson.annotations.SerializedName;
import com.lettr.core.model.OffsetPagination;

import javax.annotation.Nonnull;
import java.util.Collections;
import java.util.List;

/**
* Response from listing scheduled emails: a page of scheduled emails plus
* pagination metadata.
*/
public class ListScheduledEmailsResponse {

@SerializedName("scheduled_emails")
private List<ScheduledEmail> scheduledEmails;

private OffsetPagination pagination;

@Nonnull
public List<ScheduledEmail> getScheduledEmails() {
return scheduledEmails != null ? scheduledEmails : Collections.emptyList();
}

@Nonnull
public OffsetPagination getPagination() {
return pagination;
}

@Override
public String toString() {
return "ListScheduledEmailsResponse{scheduledEmails=" + scheduledEmails + ", pagination=" + pagination + '}';
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.lettr.services.emails.model;

import com.google.gson.annotations.SerializedName;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.List;
Expand All @@ -9,23 +11,24 @@
* Options for scheduling an email for future delivery.
*
* <p>Extends {@link CreateEmailOptions} with a required {@code scheduledAt} field.
* The scheduled time must be at least 5 minutes in the future and within 3 days.</p>
* The scheduled time must be at least 5 minutes in the future and within 30 days.</p>
*/
public class ScheduleEmailOptions extends CreateEmailOptions {

private final String scheduled_at;
@SerializedName("scheduled_at")
private final String scheduledAt;

private ScheduleEmailOptions(Builder builder) {
super(builder);
this.scheduled_at = builder.scheduledAt;
this.scheduledAt = builder.scheduledAt;
}

@Nonnull
public static Builder builder() {
return new Builder();
}

@Nonnull public String getScheduledAt() { return scheduled_at; }
@Nonnull public String getScheduledAt() { return scheduledAt; }

public static class Builder extends CreateEmailOptions.Builder {
private String scheduledAt;
Expand All @@ -34,7 +37,7 @@ protected Builder() {}

/**
* <b>(required)</b> Sets the scheduled delivery time in ISO 8601 format.
* Must be at least 5 minutes in the future and within 3 days.
* Must be at least 5 minutes in the future and within 30 days.
*/
@Nonnull
public Builder scheduledAt(@Nonnull String scheduledAt) {
Expand Down
Loading
Loading