feat(scheduled): follow the API that made scheduled emails Lettr's own - #12
Merged
Merged
Conversation
Lettr now holds a scheduled email itself and only hands it to the provider when it is due (TPL-2621), so the id that addresses one is Lettr's own request_id, prefixed sch_. transmissionId is the provider's, is null until the email actually sends, and is the id webhook events carry — which is why its @nonnull annotation had become a lie. getState() returns ScheduledEmailState rather than String. The old javadoc advertised provider states a scheduled email no longer reports at all, so "submitted".equals(e.getState()) still compiles and is now silently always false, at the exact point a caller decides whether an email still goes out. A compile error is the better outcome. Adds listScheduled: there was no way to ask what was queued. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The legacy read path reports the provider's vocabulary, not Lettr's: a delivered email comes back "delivered", which is none of the five states. Gson deserializes an unrecognised enum value to null, so getState() broke its own @nonnull contract. Adds the four provider states as deprecated constants, and a TypeAdapter that answers UNKNOWN for anything else, so a state the API adds later cannot break reads again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
voj-tech-j
added a commit
that referenced
this pull request
Sep 22, 2026
Cuts the scheduled-email work merged in #12: Lettr owns the schedule, so requestId addresses the email and transmissionId is the provider's id that webhook events carry. Also fills in the compare links for 1.6.0, 1.3.0 and 1.2.0, which were released without one, and adds the [Unreleased] link the file never had. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lettr used to hand scheduled emails straight to SparkPost. SparkPost retired per-transmission GET/DELETE, so Lettr now owns the schedule (TPL-2621) and only hands the email over when it is due. This SDK still described the old world.
The id split
requestId(sch_…) is Lettr's own. It addresses the email for its whole life and is whatgetScheduledandcancelScheduledtake — both parameters were renamed fromtransmissionIdto match.transmissionIdis the provider's. It isnulluntil the email actually sends, and it is the value that appears on webhook events.getTransmissionId()was annotated@Nonnull, which is now false for every pending email.One source-breaking change:
getState()It returns
ScheduledEmailState, notString.This is worth the break because of how it fails otherwise. The old javadoc advertised the provider's states —
submitted,generating,delivered,bounced,unknown— and a scheduled email no longer reports any of them. So"submitted".equals(e.getState())keeps compiling and is now silently and permanently false, at the exact point a caller decides whether an email is still going out. A compile error is the better outcome.Migration is
e.getState() == ScheduledEmailState.SCHEDULED. The enum carriesisCancellable()andisTerminal(); the changelog leads with this and lists all five constants.New
listScheduled(params)plus a no-arg overload — there was no way to ask what was queued.status/page/perPage, returningOffsetPagination.schedule()returnsScheduledEmailinstead ofCreateEmailResponse, andcancelScheduled()returns the cancelled email instead ofvoid.ScheduledEmailgainsrequestId,accepted,rejected,tag,failureReason.Incidental fixes in the same area
getRecipients()andgetEvents()were annotated@Nonnullbut returned the raw field, so they returnednullwhen the key was absent. They now use theCollections.emptyList()fallback the rest of the codebase uses.getSubject()is@Nullable— it is genuinely null for a template-only send.ScheduleEmailOptions.scheduled_atwas the only model field in the repo relying on its literal Java name instead of@SerializedName; an obfuscator would have broken it silently.+31d→422 "must be within the next 30 days",+29d→201.HttpClientimport.DELETE now returns a body
cancelScheduledwasvoidthroughexecuteNoResponse, since the endpoint used to answer 204. It now answers 200 with the cancelled email. No existingHttpClientmethod fit —delete(path, body, type)sends a JSON body this endpoint does not want — so there is a newdelete(String path, Type responseType), mirroring the existingpost(path, type)/post(path, body, type)pairing. All existingdeletecall sites compile unchanged.Verification
./gradlew buildand./gradlew test— 215 tests, 0 failures.All four endpoints run live against the production API through
example-java, whoseincludeBuild("../lettr-java")substitutes this working tree:schedulesch_01M323C2YEFE5ZDMGQAWVAY3QH,transmission_id: null,accepted: 1, HTTP 201getScheduledsch_id (confirmedsch_survivesencodePathSegment)cancelScheduledstate: cancelled,accepted: 0listScheduledpage=1&per_page=2&status=cancelled→{total:15, per_page:2, current_page:1, last_page:8}Everything scheduled during testing was cancelled; nothing was delivered.
The matching
example-javaharness updates are in that working tree — it is not a git repo, so they cannot be PR'd alongside.Legacy read path
Reading back an old numeric provider id still works server-side (confirmed live from the Go SDK against a real legacy id), and that response has no
request_id.getRequestId()falls back to the id the caller passed, covered by a unit test against the legacy JSON shape.🤖 Generated with Claude Code