Skip to content

feat: preparation status, folder filter and idempotent sends (TPL-2541, TPL-2539) - #18

Merged
voj-tech-j merged 1 commit into
mainfrom
feat/tpl-2541-preparation-status-idempotency
Sep 10, 2026
Merged

voj-tech-j merged 1 commit into
mainfrom
feat/tpl-2541-preparation-status-idempotency

Conversation

@voj-tech-j

Copy link
Copy Markdown
Contributor

Release 2.7.0. Two unrelated additions sharing a release, as agreed.

TPL-2541 — template preparation status + folder filter

preparation_status on all four template response DTOs, typed as the new Enums\TemplatePreparationStatus (Pending, Ready, Failed).

A response without the 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.

The status answers "is what I sent what will go out", not "can I send this." A template being prepared after an update keeps its previous render and stays sendable — it is just serving the old content. That is why the helper is ->isSettled() and not something called isReady(); naming it the latter invites wiring it into a send guard and blocking legitimate sends.

ListTemplatesFilter::folderId() narrows the list to one folder — one perPage(100) call to reconcile a whole bulk import instead of a detail call per template, each dragging the full HTML payload against the same rate limit. Appended last on the constructor so positional construction keeps working.

TPL-2539 — idempotent sends

$response = $lettr->emails()->send($email, idempotencyKey: 'order-confirmation-12345');

$response->replayed; // true → this replayed an earlier send, no second email went out

The caller supplies the key; the SDK never generates one. This is the part worth arguing with, so the reasoning: the SDK does not retry — Client::request() is a single Guzzle call with no retry middleware — so the two attempts are two separate send() calls made by your retry logic, and only you know they are the same logical send. A key minted inside send() would differ on every attempt and protect nothing while looking like it did.

IdempotencyKey validates [A-Za-z0-9._-]{1,255} locally, so a malformed key throws InvalidValueException on your machine instead of costing a round trip and a 422. IdempotencyKey::forPayload() is there for callers with no natural id, opt-in, with the trade documented: two deliberately identical sends within 24h then collapse into one.

Two distinguishable 409s, because one is safe to retry and the other is not:

  • IdempotencyInProgressException — original still processing. Retry with the same key after ->retryAfter seconds; a fresh key would send a second email.
  • IdempotencyConflictException — that key was used with a different payload. Caller bug; retrying fails forever.

Both extend ConflictException, so existing catch (ConflictException) / catch (ApiException) handlers are unaffected — same pattern as ContactAlreadyExistsException in 2.5.0.

The one design call worth reviewing

TransporterContract is untouched. Sending a header needed a route through the transporter, and adding an argument to TransporterContract::post() breaks every class implementing it — PHP fatals when a signature no longer matches, which is a 3.0.0 for a feature most callers will not use. So SupportsRequestHeaders is a small separate interface that Client implements and EmailService checks for.

The cost, stated plainly: a custom transporter that does not implement it silently sends no idempotency key rather than failing to compile. Someone with a homemade transporter could pass idempotencyKey: and get no protection with nothing telling them. There is a test asserting exactly that fallback so the behaviour is pinned rather than accidental. If you would rather have the compile error, say so and this becomes 3.0.0.

Checks

composer test — 299 passed (863 assertions). Pint and PHPStan clean.

New coverage: the preparation status on every DTO including the absent-key and unknown-value fallbacks; the folder filter surviving every other fluent setter, and sending no folder_id key when unset; and a full idempotency file — the key in the header and never the body, no header at all when unset, the send argument winning over the builder, replay detection (case-insensitive), both 409s mapped from real Guzzle responses through a MockHandler, and the custom-transporter fallback.

CHANGELOG.md, the VERSIONING.md history row, Lettr::VERSION and a README section on idempotent sends are all updated.

Not tagged — merge first, then git tag -a v2.7.0.

Next

lettr-laravel follows: the lettr/lettr-php bump plus the Laravel side of TPL-2539. The default-key question there (where a generated key comes from, since queued jobs retry on their own) is still open — that one needs your decision before I build it.

🤖 Generated with Claude Code

https://claude.ai/code/session_016uJ8Gsfq5iEPsJv6GxHUgU

…1, TPL-2539)

Two unrelated additions that share a release.

**Template preparation status.** The API now says how far an imported
template has got, so `preparation_status` lands on all four template
response DTOs as a `TemplatePreparationStatus`. A response without the key
reads as `Ready`, not `Pending` - it comes from an API deployment that
predates the field, where every template with HTML was simply usable, and
defaulting to `Pending` would make an old API look like a stalled queue and
hang anything waiting for readiness.

Note the status answers "is what I sent what will go out", not "can I send
this": a template being prepared after an update keeps its previous render
and stays sendable while serving the old content. Hence `isSettled()`
rather than something named `isReady()`.

`ListTemplatesFilter::folderId()` narrows the list to one folder, which is
what makes reconciling a bulk import cheap - one `perPage(100)` call
instead of a detail call per template, each dragging the full HTML payload
against the same rate limit. Appended last on the constructor so positional
construction keeps working.

**Idempotent sends.** `send()` takes an optional key and `EmailBuilder`
sets one fluently. The caller supplies it and the SDK never generates one:
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.

`IdempotencyKey` validates the format locally so a bad key throws before
the round trip rather than coming back as a 422. `SendEmailResponse` gains
`replayed`. The two 409s become distinct exceptions because one is safe to
retry with the same key and the other fails forever; both extend
`ConflictException` so existing handlers are unaffected.

Sending a header needed a way through the transporter, and adding an
argument to `TransporterContract::post()` would break every class
implementing it - a major release for a feature most callers will not use.
So `SupportsRequestHeaders` is a separate interface that `Client`
implements and `EmailService` checks for. The trade is that a custom
transporter silently sends no key rather than failing to compile; that is
the deliberate cost of keeping this a minor.

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