diff --git a/CHANGELOG.md b/CHANGELOG.md
index b864b4b..33ce00b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/src/main/java/com/lettr/core/net/HttpClient.java b/src/main/java/com/lettr/core/net/HttpClient.java
index 164f492..ece05a0 100644
--- a/src/main/java/com/lettr/core/net/HttpClient.java
+++ b/src/main/java/com/lettr/core/net/HttpClient.java
@@ -302,6 +302,33 @@ public void delete(String path, Map 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 response data type
+ * @return deserialized response data
+ * @throws LettrException on error
+ */
+ public 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.
diff --git a/src/main/java/com/lettr/services/emails/Emails.java b/src/main/java/com/lettr/services/emails/Emails.java
index a56ae6a..43565ec 100644
--- a/src/main/java/com/lettr/services/emails/Emails.java
+++ b/src/main/java/com/lettr/services/emails/Emails.java
@@ -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.*;
@@ -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 params = null;
if (from != null || to != null) {
params = new LinkedHashMap<>();
@@ -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)} — not 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 — not 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;
}
}
diff --git a/src/main/java/com/lettr/services/emails/model/ListScheduledEmailsParams.java b/src/main/java/com/lettr/services/emails/model/ListScheduledEmailsParams.java
new file mode 100644
index 0000000..ca331bb
--- /dev/null
+++ b/src/main/java/com/lettr/services/emails/model/ListScheduledEmailsParams.java
@@ -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 toQueryParams() {
+ Map 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() {}
+
+ /** (optional) Page number (min 1, default 1). */
+ @Nonnull
+ public Builder page(@Nullable Integer page) {
+ pageParams.page(page);
+ return this;
+ }
+
+ /** (optional) Items per page (1–100, default 25). */
+ @Nonnull
+ public Builder perPage(@Nullable Integer perPage) {
+ pageParams.perPage(perPage);
+ return this;
+ }
+
+ /** (optional) Filters by lifecycle state. */
+ @Nonnull
+ public Builder status(@Nullable ScheduledEmailState status) {
+ this.status = status;
+ return this;
+ }
+
+ @Nonnull
+ public ListScheduledEmailsParams build() {
+ return new ListScheduledEmailsParams(this);
+ }
+ }
+}
diff --git a/src/main/java/com/lettr/services/emails/model/ListScheduledEmailsResponse.java b/src/main/java/com/lettr/services/emails/model/ListScheduledEmailsResponse.java
new file mode 100644
index 0000000..d7e7499
--- /dev/null
+++ b/src/main/java/com/lettr/services/emails/model/ListScheduledEmailsResponse.java
@@ -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 scheduledEmails;
+
+ private OffsetPagination pagination;
+
+ @Nonnull
+ public List getScheduledEmails() {
+ return scheduledEmails != null ? scheduledEmails : Collections.emptyList();
+ }
+
+ @Nonnull
+ public OffsetPagination getPagination() {
+ return pagination;
+ }
+
+ @Override
+ public String toString() {
+ return "ListScheduledEmailsResponse{scheduledEmails=" + scheduledEmails + ", pagination=" + pagination + '}';
+ }
+}
diff --git a/src/main/java/com/lettr/services/emails/model/ScheduleEmailOptions.java b/src/main/java/com/lettr/services/emails/model/ScheduleEmailOptions.java
index 5b054e3..2ad6ff3 100644
--- a/src/main/java/com/lettr/services/emails/model/ScheduleEmailOptions.java
+++ b/src/main/java/com/lettr/services/emails/model/ScheduleEmailOptions.java
@@ -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;
@@ -9,15 +11,16 @@
* Options for scheduling an email for future delivery.
*
* 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.
+ * The scheduled time must be at least 5 minutes in the future and within 30 days.
*/
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
@@ -25,7 +28,7 @@ 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;
@@ -34,7 +37,7 @@ protected Builder() {}
/**
* (required) 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) {
diff --git a/src/main/java/com/lettr/services/emails/model/ScheduledEmail.java b/src/main/java/com/lettr/services/emails/model/ScheduledEmail.java
index 9b49373..58bfb3b 100644
--- a/src/main/java/com/lettr/services/emails/model/ScheduledEmail.java
+++ b/src/main/java/com/lettr/services/emails/model/ScheduledEmail.java
@@ -4,17 +4,28 @@
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import java.util.Collections;
import java.util.List;
/**
- * Represents a scheduled email transmission.
+ * A scheduled email, as Lettr holds it until its delivery time.
+ *
+ * Two identifiers, and they are not interchangeable:
+ * {@link #getRequestId() requestId} is Lettr's own {@code sch_} id and is the
+ * one that addresses this email in {@code getScheduled} / {@code cancelScheduled};
+ * {@link #getTransmissionId() transmissionId} is the sending provider's id, is
+ * {@code null} until the email actually goes out, and is the value that appears
+ * on webhook events.
*/
public class ScheduledEmail {
+ @SerializedName("request_id")
+ private String requestId;
+
@SerializedName("transmission_id")
private String transmissionId;
- private String state;
+ private ScheduledEmailState state;
@SerializedName("scheduled_at")
private String scheduledAt;
@@ -31,34 +42,76 @@ public class ScheduledEmail {
@SerializedName("num_recipients")
private int numRecipients;
+ private int accepted;
+
+ private int rejected;
+
+ private String tag;
+
+ @SerializedName("failure_reason")
+ private String failureReason;
+
private List events;
- @Nonnull public String getTransmissionId() { return transmissionId; }
+ /**
+ * Lettr's identifier for this scheduled email, prefixed {@code sch_}. Pass
+ * it to {@code getScheduled()} and {@code cancelScheduled()}.
+ */
+ @Nonnull public String getRequestId() { return requestId; }
/**
- * Current state: {@code submitted}, {@code generating}, {@code scheduled},
- * {@code delivered}, {@code bounced}, {@code failed}, or {@code unknown}.
+ * The sending provider's transmission id, or {@code null} while the email is
+ * still waiting to be sent - Lettr does not hand it to the provider until it
+ * is due. Correlate webhook events with this, not with
+ * {@link #getRequestId()}.
*/
- @Nonnull public String getState() { return state; }
+ @Nullable public String getTransmissionId() { return transmissionId; }
+
+ /** Current lifecycle state. */
+ @Nonnull public ScheduledEmailState getState() { return state; }
- /** Scheduled delivery time. May be null when the transmission has already been delivered. */
+ /** Scheduled delivery time in ISO 8601. */
@Nullable public String getScheduledAt() { return scheduledAt; }
@Nonnull public String getFrom() { return from; }
@Nullable public String getFromName() { return fromName; }
- @Nonnull public String getSubject() { return subject; }
- @Nonnull public List getRecipients() { return recipients; }
+ @Nullable public String getSubject() { return subject; }
+ @Nonnull public List getRecipients() { return recipients != null ? recipients : Collections.emptyList(); }
public int getNumRecipients() { return numRecipients; }
- /** Empty array when the email is still scheduled; populated once processed. */
- @Nonnull public List getEvents() { return events; }
+ /** Recipients accepted for delivery. Drops to 0 once the email is cancelled. */
+ public int getAccepted() { return accepted; }
+
+ /** Recipients rejected at scheduling time. */
+ public int getRejected() { return rejected; }
+
+ @Nullable public String getTag() { return tag; }
+
+ /** Why the send failed; only populated in the {@code FAILED} state. */
+ @Nullable public String getFailureReason() { return failureReason; }
+
+ /** Empty while the email is still scheduled; populated once it has been sent. */
+ @Nonnull public List getEvents() { return events != null ? events : Collections.emptyList(); }
+
+ /**
+ * @hidden set by the SDK when the API answers from pre-Lettr-scheduling
+ * delivery events, whose payload carries no {@code request_id}
+ */
+ public void setRequestId(String requestId) { this.requestId = requestId; }
@Override
public String toString() {
return "ScheduledEmail{" +
- "transmissionId='" + transmissionId + '\'' +
- ", state='" + state + '\'' +
+ "requestId='" + requestId + '\'' +
+ ", transmissionId='" + transmissionId + '\'' +
+ ", state=" + state +
", scheduledAt='" + scheduledAt + '\'' +
+ ", subject='" + subject + '\'' +
+ ", numRecipients=" + numRecipients +
+ ", accepted=" + accepted +
+ ", rejected=" + rejected +
+ ", tag='" + tag + '\'' +
+ ", failureReason='" + failureReason + '\'' +
'}';
}
}
diff --git a/src/main/java/com/lettr/services/emails/model/ScheduledEmailState.java b/src/main/java/com/lettr/services/emails/model/ScheduledEmailState.java
new file mode 100644
index 0000000..d1f12e8
--- /dev/null
+++ b/src/main/java/com/lettr/services/emails/model/ScheduledEmailState.java
@@ -0,0 +1,109 @@
+package com.lettr.services.emails.model;
+
+import com.google.gson.TypeAdapter;
+import com.google.gson.annotations.JsonAdapter;
+import com.google.gson.annotations.SerializedName;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+import java.io.IOException;
+
+/**
+ * Lifecycle state of a scheduled email.
+ *
+ * Lettr now owns the schedule itself and only hands the email to the sending
+ * provider once it is due, so these are Lettr's own states - not the provider's
+ * transmission states. A scheduled email never reports {@code delivered} or
+ * {@code bounced}: once it is {@link #SENT} the per-recipient outcome lives on
+ * the delivery events, keyed by
+ * {@link ScheduledEmail#getTransmissionId() transmissionId}.
+ *
+ *
Wire values are defined by {@code @SerializedName}; use
+ * {@link com.lettr.core.util.WireValues#of(Enum)} to retrieve them.
+ */
+@JsonAdapter(ScheduledEmailState.Adapter.class)
+public enum ScheduledEmailState {
+ @SerializedName("scheduled") SCHEDULED,
+ @SerializedName("sending") SENDING,
+ @SerializedName("sent") SENT,
+ @SerializedName("cancelled") CANCELLED,
+ @SerializedName("failed") FAILED,
+
+ /*
+ * The states below are the sending provider's, not Lettr's. Reading back a
+ * legacy provider transmission id is answered from delivery events, which
+ * report the provider's vocabulary - so these arrive on that path only,
+ * never on a sch_ id.
+ */
+
+ /** @deprecated Legacy provider state, from a numeric transmission id. */
+ @Deprecated @SerializedName("submitted") SUBMITTED,
+
+ /** @deprecated Legacy provider state, from a numeric transmission id. */
+ @Deprecated @SerializedName("generating") GENERATING,
+
+ /** @deprecated Legacy provider state, from a numeric transmission id. */
+ @Deprecated @SerializedName("delivered") DELIVERED,
+
+ /** @deprecated Legacy provider state, from a numeric transmission id. */
+ @Deprecated @SerializedName("bounced") BOUNCED,
+
+ /** A state this version of the SDK does not know. */
+ @SerializedName("unknown") UNKNOWN;
+
+ /**
+ * Whether a cancel would still stop the email going out.
+ *
+ *
Only {@link #SCHEDULED} qualifies: from {@link #SENDING} onwards the
+ * email is already with the provider, which no longer accepts a recall.
+ * This is a local pre-check, not a guarantee - the state you are holding
+ * was read at some earlier moment, so a cancel can still lose the race and
+ * fail.
+ */
+ public boolean isCancellable() {
+ return this == SCHEDULED;
+ }
+
+ /**
+ * Whether the state can still change. Polling a terminal state will never
+ * report anything new.
+ */
+ public boolean isTerminal() {
+ return this == SENT || this == CANCELLED || this == FAILED || this == DELIVERED || this == BOUNCED;
+ }
+
+ /**
+ * Reads the wire value, answering {@link #UNKNOWN} for one this version
+ * does not know.
+ *
+ *
Gson's default enum handling deserializes an unrecognised value to
+ * {@code null}, which would break {@link ScheduledEmail#getState()}'s
+ * {@code @Nonnull} contract - and a state the API adds later would then
+ * turn every read into a surprise NPE.
+ */
+ static final class Adapter extends TypeAdapter {
+ @Override
+ public void write(JsonWriter out, ScheduledEmailState value) throws IOException {
+ if (value == null) {
+ out.nullValue();
+ return;
+ }
+ out.value(com.lettr.core.util.WireValues.of(value));
+ }
+
+ @Override
+ public ScheduledEmailState read(JsonReader in) throws IOException {
+ if (in.peek() == JsonToken.NULL) {
+ in.nextNull();
+ return UNKNOWN;
+ }
+ String wire = in.nextString();
+ for (ScheduledEmailState state : values()) {
+ if (com.lettr.core.util.WireValues.of(state).equals(wire)) {
+ return state;
+ }
+ }
+ return UNKNOWN;
+ }
+ }
+}
diff --git a/src/test/java/com/lettr/services/emails/EmailsTest.java b/src/test/java/com/lettr/services/emails/EmailsTest.java
index 44ff3a9..84694e9 100644
--- a/src/test/java/com/lettr/services/emails/EmailsTest.java
+++ b/src/test/java/com/lettr/services/emails/EmailsTest.java
@@ -2,12 +2,13 @@
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
+import com.lettr.core.util.WireValues;
import com.lettr.services.emails.model.*;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
-import java.util.List;
+import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@@ -376,18 +377,156 @@ void getEmailResponseDeserializes() {
@Test
void scheduledEmailDeserializes() {
- String json = "{\"transmission_id\":\"123\",\"state\":\"submitted\"," +
- "\"scheduled_at\":\"2024-01-16T10:00:00+00:00\"," +
- "\"from\":\"sender@example.com\",\"subject\":\"Newsletter\"," +
- "\"recipients\":[\"user@example.com\"],\"num_recipients\":1,\"events\":[]}";
+ String json = "{\"request_id\":\"sch_01M322YMWVCZ4RNYXHMSSMDTM1\",\"transmission_id\":null," +
+ "\"state\":\"scheduled\",\"scheduled_at\":\"2026-09-21T15:37:10Z\"," +
+ "\"from\":\"hello@dev.uselettr.com\",\"from_name\":null,\"subject\":\"sdk audit probe\"," +
+ "\"recipients\":[\"vojta@ecomail.cz\"],\"num_recipients\":1," +
+ "\"accepted\":1,\"rejected\":0,\"tag\":null,\"failure_reason\":null,\"events\":[]}";
ScheduledEmail response = gson.fromJson(json, ScheduledEmail.class);
- assertEquals("123", response.getTransmissionId());
- assertEquals("submitted", response.getState());
- assertEquals("2024-01-16T10:00:00+00:00", response.getScheduledAt());
+ assertEquals("sch_01M322YMWVCZ4RNYXHMSSMDTM1", response.getRequestId());
+ assertNull(response.getTransmissionId());
+ assertEquals(ScheduledEmailState.SCHEDULED, response.getState());
+ assertEquals("2026-09-21T15:37:10Z", response.getScheduledAt());
+ assertEquals("sdk audit probe", response.getSubject());
+ assertNull(response.getFromName());
+ assertEquals(1, response.getNumRecipients());
+ assertEquals(1, response.getAccepted());
+ assertEquals(0, response.getRejected());
+ assertNull(response.getTag());
+ assertNull(response.getFailureReason());
+ assertTrue(response.getEvents().isEmpty());
+ }
+
+ @Test
+ void scheduledEmailTransmissionIdIsNullUntilSent() {
+ String json = "{\"request_id\":\"sch_1\",\"transmission_id\":null,\"state\":\"scheduled\"}";
+
+ ScheduledEmail response = gson.fromJson(json, ScheduledEmail.class);
+ assertNull(response.getTransmissionId());
+ assertEquals("sch_1", response.getRequestId());
+ }
+
+ @Test
+ void scheduledEmailCollectionsDefaultWhenKeysAbsent() {
+ ScheduledEmail response = gson.fromJson("{\"request_id\":\"sch_1\",\"state\":\"scheduled\"}", ScheduledEmail.class);
+ assertTrue(response.getRecipients().isEmpty());
assertTrue(response.getEvents().isEmpty());
}
+ @Test
+ void scheduledEmailStateRoundTripsEveryWireValue() {
+ Map expected = new LinkedHashMap<>();
+ expected.put("scheduled", ScheduledEmailState.SCHEDULED);
+ expected.put("sending", ScheduledEmailState.SENDING);
+ expected.put("sent", ScheduledEmailState.SENT);
+ expected.put("cancelled", ScheduledEmailState.CANCELLED);
+ expected.put("failed", ScheduledEmailState.FAILED);
+ // Only the legacy read path produces these, but it does produce them.
+ expected.put("submitted", ScheduledEmailState.SUBMITTED);
+ expected.put("generating", ScheduledEmailState.GENERATING);
+ expected.put("delivered", ScheduledEmailState.DELIVERED);
+ expected.put("bounced", ScheduledEmailState.BOUNCED);
+ expected.put("unknown", ScheduledEmailState.UNKNOWN);
+
+ for (Map.Entry entry : expected.entrySet()) {
+ ScheduledEmail email = gson.fromJson("{\"state\":\"" + entry.getKey() + "\"}", ScheduledEmail.class);
+ assertEquals(entry.getValue(), email.getState(), entry.getKey());
+ assertEquals(entry.getKey(), WireValues.of(entry.getValue()));
+ }
+ assertEquals(expected.size(), ScheduledEmailState.values().length);
+ }
+
+ @Test
+ void scheduledEmailStateHelpers() {
+ assertTrue(ScheduledEmailState.SCHEDULED.isCancellable());
+ assertFalse(ScheduledEmailState.SENDING.isCancellable());
+ assertFalse(ScheduledEmailState.SENT.isCancellable());
+
+ assertFalse(ScheduledEmailState.SCHEDULED.isTerminal());
+ assertFalse(ScheduledEmailState.SENDING.isTerminal());
+ assertTrue(ScheduledEmailState.SENT.isTerminal());
+ assertTrue(ScheduledEmailState.CANCELLED.isTerminal());
+ assertTrue(ScheduledEmailState.FAILED.isTerminal());
+ }
+
+ @Test
+ void scheduledEmailCancelledShape() {
+ String json = "{\"request_id\":\"sch_1\",\"transmission_id\":null,\"state\":\"cancelled\"," +
+ "\"accepted\":0,\"rejected\":0,\"events\":[]}";
+
+ ScheduledEmail response = gson.fromJson(json, ScheduledEmail.class);
+ assertEquals(ScheduledEmailState.CANCELLED, response.getState());
+ assertEquals(0, response.getAccepted());
+ }
+
+ @Test
+ void scheduledEmailLegacyShapeHasNoRequestId() {
+ // Pre-Lettr-scheduling emails are answered from delivery events: no
+ // request_id, no accepted/rejected/tag/failure_reason.
+ String json = "{\"transmission_id\":\"7628931605070913075\",\"state\":\"sent\"," +
+ "\"scheduled_at\":\"2026-01-16T10:00:00+00:00\",\"from\":\"sender@example.com\"," +
+ "\"subject\":\"Newsletter\",\"recipients\":[\"user@example.com\"],\"num_recipients\":1,\"events\":[]}";
+
+ ScheduledEmail response = gson.fromJson(json, ScheduledEmail.class);
+ assertNull(response.getRequestId());
+ assertEquals("7628931605070913075", response.getTransmissionId());
+ assertEquals(0, response.getAccepted());
+
+ // Emails.getScheduled()/cancelScheduled() fill the gap with the id the
+ // caller asked about, so getRequestId() still addresses the email.
+ response.setRequestId("7628931605070913075");
+ assertEquals("7628931605070913075", response.getRequestId());
+ }
+
+ @Test
+ void listScheduledEmailsResponseDeserializes() {
+ String json = "{\"scheduled_emails\":[" +
+ "{\"request_id\":\"sch_a\",\"transmission_id\":null,\"state\":\"scheduled\"," +
+ "\"scheduled_at\":\"2026-09-28T09:50:53Z\",\"from\":\"hello@dev.uselettr.com\"," +
+ "\"subject\":\"ahoj\",\"recipients\":[\"vojta@ecomail.cz\"],\"num_recipients\":1," +
+ "\"accepted\":1,\"rejected\":0,\"events\":[]}," +
+ "{\"request_id\":\"sch_b\",\"transmission_id\":null,\"state\":\"cancelled\"," +
+ "\"accepted\":0,\"rejected\":0,\"events\":[]}]," +
+ "\"pagination\":{\"total\":2,\"per_page\":2,\"current_page\":1,\"last_page\":1}}";
+
+ ListScheduledEmailsResponse response = gson.fromJson(json, ListScheduledEmailsResponse.class);
+ assertEquals(2, response.getScheduledEmails().size());
+ assertEquals("sch_a", response.getScheduledEmails().get(0).getRequestId());
+ assertEquals(ScheduledEmailState.CANCELLED, response.getScheduledEmails().get(1).getState());
+ assertEquals(2, response.getPagination().getTotal());
+ assertEquals(2, response.getPagination().getPerPage());
+ assertEquals(1, response.getPagination().getCurrentPage());
+ assertEquals(1, response.getPagination().getLastPage());
+ }
+
+ @Test
+ void listScheduledEmailsResponseDefaultsToEmptyList() {
+ ListScheduledEmailsResponse response = gson.fromJson("{}", ListScheduledEmailsResponse.class);
+ assertTrue(response.getScheduledEmails().isEmpty());
+ }
+
+ // --- ListScheduledEmailsParams tests ---
+
+ @Test
+ void listScheduledEmailsParamsToQueryParams() {
+ ListScheduledEmailsParams params = ListScheduledEmailsParams.builder()
+ .page(2)
+ .perPage(50)
+ .status(ScheduledEmailState.SCHEDULED)
+ .build();
+
+ Map queryParams = params.toQueryParams();
+ assertEquals("2", queryParams.get("page"));
+ assertEquals("50", queryParams.get("per_page"));
+ assertEquals("scheduled", queryParams.get("status"));
+ }
+
+ @Test
+ void listScheduledEmailsParamsEmptyToQueryParams() {
+ assertTrue(ListScheduledEmailsParams.builder().build().toQueryParams().isEmpty());
+ }
+
// --- Attachment builder tests ---
@Test
@@ -423,16 +562,47 @@ void emailsGetRequiresRequestId() {
}
@Test
- void emailsGetScheduledRequiresTransmissionId() {
+ void emailsGetScheduledRequiresRequestId() {
Emails emails = new Emails("test-key");
assertThrows(IllegalArgumentException.class, () -> emails.getScheduled(null));
assertThrows(IllegalArgumentException.class, () -> emails.getScheduled(""));
}
@Test
- void emailsCancelScheduledRequiresTransmissionId() {
+ void emailsCancelScheduledRequiresRequestId() {
Emails emails = new Emails("test-key");
assertThrows(IllegalArgumentException.class, () -> emails.cancelScheduled(null));
assertThrows(IllegalArgumentException.class, () -> emails.cancelScheduled(""));
}
+
+ @Test
+ void scheduledEmailReadsLegacyProviderState() {
+ // The legacy read path reports the provider's vocabulary, not Lettr's.
+ // A delivered email comes back "delivered", which is in neither the
+ // five Lettr states nor any sch_ response.
+ String json = "{\"transmission_id\":\"7685727204621206387\",\"state\":\"delivered\","
+ + "\"scheduled_at\":null,\"from\":\"sender@example.com\",\"from_name\":null,"
+ + "\"subject\":\"Legacy\",\"recipients\":[\"r@example.com\"],\"num_recipients\":1,"
+ + "\"events\":[]}";
+
+ ScheduledEmail email = new Gson().fromJson(json, ScheduledEmail.class);
+
+ assertEquals(ScheduledEmailState.DELIVERED, email.getState());
+ assertTrue(email.getState().isTerminal());
+ assertFalse(email.getState().isCancellable());
+ }
+
+ @Test
+ void unrecognisedStateReadsAsUnknownRatherThanNull() {
+ // Gson's default enum handling answers null here, which would break
+ // getState()'s @Nonnull contract for any state the API adds later.
+ String json = "{\"request_id\":\"sch_01JQZ3N2K8XW9V6M4TBRC7YHDE\","
+ + "\"state\":\"a_state_from_the_future\",\"from\":\"sender@example.com\","
+ + "\"recipients\":[],\"num_recipients\":0,\"events\":[]}";
+
+ ScheduledEmail email = new Gson().fromJson(json, ScheduledEmail.class);
+
+ assertEquals(ScheduledEmailState.UNKNOWN, email.getState());
+ assertFalse(email.getState().isCancellable());
+ }
}