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()); + } }