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. 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.

campaigns = client.folders.list(purpose="campaign").folders[0]

client.templates.create(
    name="October Newsletter",
    json=topol_json,
    folder_id=campaigns.id,
    purpose="campaign",
)

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

Template purpose and preparation_status

purpose ("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:

  • It is not "can I send this". After an update the previous render stays in place, so a "pending" template is still sendable — it is serving the old content.
  • A missing key reads as "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=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 raises NotFoundError rather than returning an empty list — a typo cannot be misread as "nothing is there yet".

Idempotent sends

result = client.emails.send(..., idempotency_key="order-confirmation-12345")
result.replayed  # True → replayed an earlier send, no second email went out

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 inside send() would differ on every attempt and protect nothing while looking like it did.

A malformed key raises ValidationError before 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 — carries retry_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, so replayed is definitionally False and reading response headers would be dead weight. It also means every existing test double keeps working untouched — which is how I found the shape.

ApiClient gained request_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() and post() are unchanged, and raise_for_status() takes an optional third headers argument that existing two-argument calls ignore.

Checks

pytest179 passed. ruff check, ruff format --check and mypy src all 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 that raise_for_status still works without headers), plus a TestPreparationStatusAndFolderFilter block in tests/test_templates.py.

pyproject.toml bumped to 1.6.0 and a CHANGELOG entry added. _version.py reads from package metadata, so nothing to change there.

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.

`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
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