Skip to content

feat(scheduled): follow the API that made scheduled emails Lettr's own - #12

Merged
voj-tech-j merged 2 commits into
mainfrom
fix/scheduled-emails
Sep 22, 2026
Merged

voj-tech-j merged 2 commits into
mainfrom
fix/scheduled-emails

Conversation

@voj-tech-j

Copy link
Copy Markdown
Contributor

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 what getScheduled and cancelScheduled take — both parameters were renamed from transmissionId to match.
  • transmissionId is the provider's. It is null until 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, not String.

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 carries isCancellable() and isTerminal(); 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, returning OffsetPagination.
  • schedule() returns ScheduledEmail instead of CreateEmailResponse, and cancelScheduled() returns the cancelled email instead of void.
  • ScheduledEmail gains requestId, accepted, rejected, tag, failureReason.

Incidental fixes in the same area

  • getRecipients() and getEvents() were annotated @Nonnull but returned the raw field, so they returned null when the key was absent. They now use the Collections.emptyList() fallback the rest of the codebase uses.
  • getSubject() is @Nullable — it is genuinely null for a template-only send.
  • ScheduleEmailOptions.scheduled_at was the only model field in the repo relying on its literal Java name instead of @SerializedName; an obfuscator would have broken it silently.
  • The window javadoc said 3 days. That was SparkPost's. The API confirms 30: +31d → 422 "must be within the next 30 days", +29d → 201.
  • Removed a duplicate HttpClient import.

DELETE now returns a body

cancelScheduled was void through executeNoResponse, since the endpoint used to answer 204. It now answers 200 with the cancelled email. No existing HttpClient method fit — delete(path, body, type) sends a JSON body this endpoint does not want — so there is a new delete(String path, Type responseType), mirroring the existing post(path, type) / post(path, body, type) pairing. All existing delete call sites compile unchanged.

Verification

./gradlew build and ./gradlew test — 215 tests, 0 failures.

All four endpoints run live against the production API through example-java, whose includeBuild("../lettr-java") substitutes this working tree:

schedule sch_01M323C2YEFE5ZDMGQAWVAY3QH, transmission_id: null, accepted: 1, HTTP 201
getScheduled same object by its sch_ id (confirmed sch_ survives encodePathSegment)
cancelScheduled HTTP 200 with a body, state: cancelled, accepted: 0
listScheduled 16 rows; with page=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-java harness 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

voj-tech-j and others added 2 commits September 21, 2026 15:53
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>
@voj-tech-j
voj-tech-j merged commit 0cebd81 into main Sep 22, 2026
2 checks passed
@voj-tech-j voj-tech-j mentioned this pull request Sep 22, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant