feat: preparation status, folder filter and idempotent sends (TPL-2541, TPL-2539) - #18
Merged
Merged
Conversation
…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
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 2.7.0. Two unrelated additions sharing a release, as agreed.
TPL-2541 — template preparation status + folder filter
preparation_statuson all four template response DTOs, typed as the newEnums\TemplatePreparationStatus(Pending,Ready,Failed).A response without the key reads as
Ready, notPending. It comes from an API deployment that predates the field, where every template with HTML was simply usable. Defaulting toPendingwould 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 calledisReady(); naming it the latter invites wiring it into a send guard and blocking legitimate sends.ListTemplatesFilter::folderId()narrows the list to one folder — oneperPage(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
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 separatesend()calls made by your retry logic, and only you know they are the same logical send. A key minted insidesend()would differ on every attempt and protect nothing while looking like it did.IdempotencyKeyvalidates[A-Za-z0-9._-]{1,255}locally, so a malformed key throwsInvalidValueExceptionon 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->retryAfterseconds; 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 existingcatch (ConflictException)/catch (ApiException)handlers are unaffected — same pattern asContactAlreadyExistsExceptionin 2.5.0.The one design call worth reviewing
TransporterContractis untouched. Sending a header needed a route through the transporter, and adding an argument toTransporterContract::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. SoSupportsRequestHeadersis a small separate interface thatClientimplements andEmailServicechecks 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_idkey 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 aMockHandler, and the custom-transporter fallback.CHANGELOG.md, theVERSIONING.mdhistory row,Lettr::VERSIONand 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-phpbump 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