feat: purpose, folders, preparation status and idempotent sends (TPL-2543, TPL-2539) - #10
Merged
Merged
Conversation
…2543, TPL-2539) Brings this client level with lettr-php 2.7.0. Everything is additive. `client.folders.list()` is the one that unblocks the rest: nothing in the SDK ever returned a folder id, so `templates.create(folder_id=...)` 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. Template `purpose` on create, on every response and as a list filter, and `preparation_status` on every response. The status answers "is what I sent what will go out", not "can I send this" - after an update the previous render stays live, so a pending template is still sendable while serving the old content. A response without either field reads as the pre-existing behaviour (`transactional`, `ready`) rather than as a stalled queue. `templates.list(folder_id=...)` 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. `emails.send(idempotency_key=...)` is caller-supplied. The SDK does not retry - one `send()` is one HTTP request - so the retry belongs to the caller, and only they know two calls are the same logical send; a key minted inside `send()` would differ on every attempt and protect nothing. A malformed key raises before any request goes out. The two 409s become distinct exceptions under `ConflictError`, because `idempotency_in_progress` must be retried with the same key while `idempotency_key_conflict` will fail identically forever. A keyless send takes the plain `post()` path rather than reading response headers: without a key there is nothing to replay, so `replayed` is definitionally false and the extra plumbing would be dead weight. `raise_for_status()` gained an optional `headers` argument for `Retry-After`; existing two-argument calls are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uJ8Gsfq5iEPsJv6GxHUgU
This was referenced Sep 10, 2026
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.
Release 1.6.0. Brings this client level with lettr-php 2.7.0 and lettr-node. Everything is additive — code written against 1.5.1 keeps working and sends identical requests.
client.folders.list()The one that unblocks the rest.
templates.create(folder_id=...)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.Read-only by design: deleting a folder moves or deletes the templates inside it, so that stays in the app.
Template
purposeandpreparation_statuspurpose("transactional" | "campaign") on create, on every response, and as a list filter.preparation_status("pending" | "ready" | "failed") on every response.Two things about the status that are easy to get wrong, and are commented in the code:
"pending"template is still sendable — it is serving the old content."ready", not"pending". It comes from an API deployment that predates the field, where every template with HTML was simply usable. Defaulting to"pending"would make an old API look like a stalled queue and hang anything waiting for readiness.templates.list(folder_id=...)One
per_page=100call 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 raisesNotFoundErrorrather than returning an empty list — a typo cannot be misread as "nothing is there yet".Idempotent sends
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, and only you know two calls are the same logical send. A key minted insidesend()would differ on every attempt and protect nothing while looking like it did.A malformed key raises
ValidationErrorbefore any request goes out.is_valid_idempotency_key()is exported for callers deriving keys from their own ids.Two new exceptions under
ConflictError, because one is safe to retry and the other is not:IdempotencyInProgressError— carriesretry_after; retry with the same key.IdempotencyConflictError— that key was used with a different payload. Caller bug; retrying fails forever.Two implementation notes worth a look
A keyless send takes the plain
post()path. Without a key there is nothing to replay, soreplayedis definitionallyFalseand reading response headers would be dead weight. It also means every existing test double keeps working untouched — which is how I found the shape.ApiClientgainedrequest_with_headers()/post_with_headers()for the one place a response header carries meaning. Separate methods rather than state on the client, so two calls cannot read each other's headers.request()andpost()are unchanged, andraise_for_status()takes an optional thirdheadersargument that existing two-argument calls ignore.Checks
pytest— 179 passed.ruff check,ruff format --checkandmypy srcall clean.New:
tests/test_folders.py(shape, every filter, the pre-field fallback, reachable from the client),tests/test_idempotency.py(key format, local rejection before any request, header not body, replay detection case-insensitively, both 409s, and thatraise_for_statusstill works without headers), plus aTestPreparationStatusAndFolderFilterblock intests/test_templates.py.pyproject.tomlbumped to 1.6.0 and a CHANGELOG entry added._version.pyreads from package metadata, so nothing to change there.Not tagged.
🤖 Generated with Claude Code
https://claude.ai/code/session_016uJ8Gsfq5iEPsJv6GxHUgU