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

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.5.0. Last of the five in TPL-2543/TPL-2539, level with lettr-php 2.7.0, node, python, go and java. Everything is additive.

client.folders.list()

The one that unblocks the rest. CreateTemplateOptions::with_folder_id has existed for ages, but nothing in the SDK ever returned a folder id.

let folders = client.folders
    .list(ListFoldersOptions::new().purpose(TemplatePurpose::Campaign))
    .await?;

client.templates.create(
    CreateTemplateOptions::new("October Newsletter")
        .with_json(topol_json)
        .with_folder_id(folders.folders[0].id)
        .with_purpose(TemplatePurpose::Campaign),
).await?;

Read-only: deleting a folder moves or deletes the templates inside it.

TemplatePurpose and TemplatePreparationStatus

Both follow the shape CampaignStatus already set in this crate — an Unknown(String) variant so a value added server-side deserializes rather than failing — with #[serde(default = ...)] on each field so an absent one reads as the pre-existing behaviour.

Ready is the default, not Pending — on an API deployment that predates the field every template with HTML was simply usable, and Pending would look like a stalled queue and hang anything waiting for readiness.

is_settled(), not is_ready() — 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.

ListTemplatesOptions::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 is a 404, not an empty list.

Idempotent sends

let response = client.emails.send(
    CreateEmailOptions::new(from, to, subject)
        .with_html(html)
        .with_idempotency_key("order-confirmation-12345"),
).await?;

response.replayed; // true → replayed an earlier send, no second email went out

The key lives on CreateEmailOptions, not as a send argument. That way send and send_with_quota both pick it up and neither signature changed — Rust has no optional parameters, so adding one would have broken every caller to serve a minority of calls, and a third send_with_idempotency_key would have made it combinatorial with the quota variant. It is #[serde(skip)], so the request body stays byte-identical, and there's a test asserting exactly that.

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 returns Error::Validation before any request goes out; is_valid_idempotency_key is public for callers deriving keys from their own ids.

Error::is_idempotency_in_progress() (retry with the same key after Error::retry_after() seconds) and Error::is_idempotency_conflict() (that key was used with a different payload — retrying fails forever). Retry-After is read in Config::send before text() consumes the response.

Checks

cargo test — all 17 suites pass, including doctests. cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.

New: tests/folders_test.rs (shape, every filter, the pre-field default), tests/preparation_status_test.rs (all three statuses in one list call, both filters, the pre-field default, is_settled semantics), tests/idempotency_test.rs (key format, and that setting a key leaves the serialized body byte-identical).

Cargo.toml bumped to 1.5.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.

`client.folders.list()` is the one that unblocks the rest: nothing in the
SDK ever returned a folder id, so `with_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.

`TemplatePurpose` and `TemplatePreparationStatus` follow the shape
`CampaignStatus` already set here - an `Unknown(String)` variant so a value
added server-side deserializes rather than failing - with a `#[serde(default
= ...)]` on each field so an absent one reads as the pre-existing
behaviour. `Ready` is the default rather than `Pending` because on an API
that predates the field every template with HTML was simply usable, and
`Pending` would look like a stalled queue.

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

The idempotency key lives on `CreateEmailOptions` rather than as a `send`
argument, so `send` and `send_with_quota` both pick it up and neither
signature changed - adding a parameter would have broken every caller to
serve a minority of calls. It is `#[serde(skip)]`, so the request body
stays byte-identical. 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 returns
`Error::Validation` before any request goes out.

The two 409s get separate predicates because one is safe to retry with the
same key and the other fails forever. `Retry-After` is read in `Config::send`
before `text()` consumes the response, and travels on `ApiError` so the
retryable one says how long to wait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uJ8Gsfq5iEPsJv6GxHUgU
@voj-tech-j
voj-tech-j force-pushed the feat/tpl-2543-2539-sdk-parity branch from 52e2a1d to 72aec67 Compare September 11, 2026 09:04
@voj-tech-j
voj-tech-j merged commit 6848af1 into main Sep 11, 2026
7 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