Skip to content

feat: purpose, folders, preparation status and idempotent sends (TPL-2543, TPL-2539) - #10

Merged
voj-tech-j merged 1 commit into
mainfrom
feat/tpl-2543-2539-sdk-parity
Sep 11, 2026
Merged

feat: purpose, folders, preparation status and idempotent sends (TPL-2543, TPL-2539)#10
voj-tech-j merged 1 commit into
mainfrom
feat/tpl-2543-2539-sdk-parity

Conversation

@voj-tech-j

Copy link
Copy Markdown
Contributor

Release 1.6.0. Fourth of the five in TPL-2543/TPL-2539, level with lettr-php 2.7.0, node, python and go. Everything is additive — code written against 1.5.1 keeps compiling and sends identical requests.

lettr.folders().list()

The one that unblocks the rest. CreateTemplateOptions.folderId() has existed for ages, but nothing in the SDK ever returned a folder id — so a caller either omitted it and accepted whichever folder the API picked, or hardcoded an integer read out of an app URL.

Folder campaigns = lettr.folders()
    .list(ListFoldersParams.builder().purpose(TemplatePurpose.CAMPAIGN).build())
    .getFolders().get(0);

lettr.templates().create(CreateTemplateOptions.builder()
    .name("October Newsletter")
    .json(topolJson)
    .folderId(campaigns.getId())
    .purpose(TemplatePurpose.CAMPAIGN)
    .build());

Read-only: deleting a folder moves or deletes the templates inside it, so that stays in the app.

TemplatePurpose and TemplatePreparationStatus

Gson-annotated enums, on create, on every template response, and as ListTemplatesParams filters.

Both getters default rather than returning nullTRANSACTIONAL and READY — because on an API deployment that predates the fields every template with HTML was simply usable, and a null would push that decision onto every caller. Defaulting to PENDING would make an older API look like a stalled queue and hang anything waiting for readiness.

isSettled(), not isReady() — deliberately. It answers "is what I sent what will go out", which is not "can I send this": after an update the previous render stays in place, so a pending template is still sendable while serving the old content. A name like isReady() invites wiring it into a send guard that blocks legitimate sends.

ListTemplatesParams.folderId()

One perPage(100) call reconciles a whole bulk import instead of a detail call per template, each dragging the full HTML payload against the same rate limit. A folder outside the resolved project is a 404, not an empty list.

Idempotent sends

CreateEmailResponse response = lettr.emails().send(options, "order-confirmation-12345");
response.isReplayed(); // true → replayed an earlier send, no second email went out

A second send() overload, not a changed signature, so existing single-argument calls are untouched.

You choose the key; the SDK never generates one. It only works if both attempts use the same value, and the SDK does not retry — one send() is one HTTP request — so the retry is yours. A malformed key throws IllegalArgumentException before any request is made; IdempotencyKeys.isValid() is public for callers deriving keys from their own ids.

Two new exceptions extending LettrApiException, so existing handlers keep catching them: IdempotencyInProgressException (carries getRetryAfter(), retry with the same key) and IdempotencyConflictException (that key was used with a different payload — retrying fails forever).

One structural choice worth reviewing

The 409 mapping lives in handleErrorResponse, not in the service — which differs from ContactAlreadyExistsException, where AudienceContacts catches and rewraps. The reason: that exception carries the submitted email, which only the service knows, whereas Retry-After is a transport concern the service never sees. Putting it in the service would have meant plumbing headers out just to throw.

That required threading HttpHeaders through executehandleErrorResponse. execute() keeps its signature and delegates to a new executeWithHeaders(), and HttpClient gained postWithHeaders() plus a small ApiResponse<T> holder — returning headers rather than storing them, so concurrent calls cannot read each other's.

Checks

./gradlew build passes — compile, tests and checks.

New: FoldersTest (query params, deserialization, the pre-field default, response shape), IdempotencyKeysTest (format, and that validation throws before any request), plus a block in TemplatesTest covering the folder/purpose filters, both filters absent when unset, enum deserialization, the pre-field defaults, isSettled() semantics, and purpose serialized only when set.

gradle.properties bumped to 1.6.0 and a CHANGELOG entry added. Not tagged.

🤖 Generated with Claude Code

https://claude.ai/code/session_016uJ8Gsfq5iEPsJv6GxHUgU

…2543, TPL-2539)

Brings this client level with lettr-php 2.7.0. Everything is additive.

`folders().list()` is the one that unblocks the rest: nothing in the SDK
ever returned a folder id, so `CreateTemplateOptions.folderId()` could only
be used by hardcoding an integer read out of an app URL. Read-only, because
deleting a folder moves or deletes the templates inside it.

`TemplatePurpose` and `TemplatePreparationStatus` as Gson-annotated enums,
on create, on every template response and as list filters. Both getters
default rather than returning null when the API omits the field -
transactional and ready - because on a deployment that predates them every
template with HTML was simply usable, and a null would push the decision
onto every caller.

`isSettled()` rather than `isReady()`: after an update the previous render
stays live, so a pending template is still sendable while serving the old
content, and a name like `isReady()` invites wiring it into a send guard
that blocks legitimate sends.

`ListTemplatesParams.folderId()` reconciles a bulk import in one call
instead of a detail call per template, each dragging the full HTML payload
against the same rate limit.

Idempotency arrives as a second `send()` overload rather than a changed
signature, so existing single-argument calls are untouched. The key is
caller-supplied: the SDK does not retry, so the retry belongs to the
caller, and a key minted inside `send()` would differ on every attempt and
protect nothing. A malformed key throws before any request goes out.

The two 409s become separate exceptions under `LettrApiException` because
one is safe to retry with the same key and the other fails forever. That
mapping lives in `handleErrorResponse` rather than in the service - unlike
`ContactAlreadyExistsException`, which needs the submitted email and so has
to be rewrapped where it is known - because `Retry-After` is a transport
concern the service never sees.

`HttpClient` gained `postWithHeaders()` and an `ApiResponse<T>` holder for
the one place a response header carries meaning; the existing methods are
unchanged and `execute()` now delegates to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uJ8Gsfq5iEPsJv6GxHUgU
@voj-tech-j
voj-tech-j merged commit 9cf3503 into main Sep 11, 2026
2 checks passed
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