From 5f3c6710211e03ef7e4f69ced67a5fc1e5901359 Mon Sep 17 00:00:00 2001 From: voj-tech-j Date: Thu, 10 Sep 2026 12:02:05 +0200 Subject: [PATCH] feat: purpose, folders, preparation status and idempotent sends (TPL-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 Claude-Session: https://claude.ai/code/session_016uJ8Gsfq5iEPsJv6GxHUgU --- CHANGELOG.md | 27 +++++ pyproject.toml | 2 +- src/lettr/__init__.py | 26 ++++- src/lettr/_client.py | 50 +++++++-- src/lettr/_exceptions.py | 81 ++++++++++++++- src/lettr/_idempotency.py | 45 ++++++++ src/lettr/_types.py | 56 ++++++++++ src/lettr/resources/__init__.py | 12 ++- src/lettr/resources/emails.py | 42 +++++++- src/lettr/resources/folders.py | 92 +++++++++++++++++ src/lettr/resources/templates.py | 35 ++++++- tests/test_folders.py | 102 ++++++++++++++++++ tests/test_idempotency.py | 172 +++++++++++++++++++++++++++++++ tests/test_templates.py | 156 ++++++++++++++++++++++++++++ 14 files changed, 882 insertions(+), 16 deletions(-) create mode 100644 src/lettr/_idempotency.py create mode 100644 src/lettr/resources/folders.py create mode 100644 tests/test_folders.py create mode 100644 tests/test_idempotency.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8172540..33d0138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.0] - 2026-09-10 + +Brings this client level with lettr-php: template modules, the folders endpoint, preparation status, and idempotent sends. Everything is additive - code written against 1.5.1 keeps working and sends identical requests. + +### Added + +- **`client.folders.list()`** - the folders templates are filed into, each with its `purpose` and `templates_count`. This is what `templates.create(folder_id=...)` was missing: nothing else 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 `purpose`** (`"transactional" | "campaign"`) on create and on every template response, plus a `purpose` filter on `list()`. Only campaign templates can be picked by the campaign builder; only transactional ones can be sent as single emails. +- **`preparation_status`** (`"pending" | "ready" | "failed"`) on every template response. Creating or updating a template defers image migration and HTML rendering to a background job; this says whether the content you sent is the content that will go out. + + It is **not** the same question as "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 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 `"pending"` would look like a stalled queue. +- **`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, so a typo cannot be misread as "nothing is there yet". +- **`emails.send(..., idempotency_key=...)`** - reuse the key when you retry and the API returns the original result instead of delivering a second email. `SendEmailResponse` gained `replayed`, true when that happened. + + **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 generated inside `send()` would differ on every attempt and protect nothing. + + A malformed key raises `ValidationError` **before any request goes out**. `is_valid_idempotency_key()` is exported for callers deriving keys from their own ids. +- **`IdempotencyInProgressError`** and **`IdempotencyConflictError`**, both subclasses of `ConflictError`, because one is safe to retry and the other is not. The first carries `retry_after` and must be retried with the *same* key; the second means that key was used with a different payload and will fail identically forever. + +### Notes + +- Keys are scoped per team **and** API key, so the same string through a different API key is a different key. The provider retains one for 24 hours. +- `ApiClient` gained `request_with_headers()` / `post_with_headers()` for the one place a response header carries meaning. `request()` and `post()` are unchanged. +- `raise_for_status()` takes an optional third `headers` argument. Existing two-argument calls behave exactly as before. + ## [1.5.1] - 2026-08-15 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 4eec0f4..97981f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lettr" -version = "1.5.1" +version = "1.6.0" description = "Official Python SDK for the Lettr Email API" readme = "README.md" license = "MIT" diff --git a/src/lettr/__init__.py b/src/lettr/__init__.py index e913834..56c2413 100644 --- a/src/lettr/__init__.py +++ b/src/lettr/__init__.py @@ -23,6 +23,8 @@ ConflictError, ContactAlreadyExistsError, ForbiddenError, + IdempotencyConflictError, + IdempotencyInProgressError, LettrError, NotFoundError, RateLimitError, @@ -73,6 +75,8 @@ EmailEventList, EmailList, EmailOptions, + Folder, + FolderList, GeoIp, HealthCheck, MergeTag, @@ -86,13 +90,24 @@ TemplateHtml, TemplateList, TemplateMergeTags, + TemplatePreparationStatus, + TemplatePurpose, TopicSubscription, TopicSubscriptionState, UserAgentParsed, Webhook, ) from ._version import __version__ -from .resources import Audience, Campaigns, Domains, Emails, Projects, Templates, Webhooks +from .resources import ( + Audience, + Campaigns, + Domains, + Emails, + Folders, + Projects, + Templates, + Webhooks, +) __all__ = [ # Version @@ -105,6 +120,8 @@ "BadRequestError", "ConflictError", "ContactAlreadyExistsError", + "IdempotencyConflictError", + "IdempotencyInProgressError", "ForbiddenError", "NotFoundError", "RateLimitError", @@ -159,12 +176,16 @@ "HealthCheck", "MergeTag", "MergeTagChild", + "Folder", + "FolderList", "Project", "ProjectList", "ScheduledEmail", "SendEmailResponse", "SpfValidationResult", "Template", + "TemplatePreparationStatus", + "TemplatePurpose", "TemplateHtml", "TemplateList", "TemplateMergeTags", @@ -238,6 +259,9 @@ def __init__( self.projects = Projects(self._client) """Project management operations.""" + self.folders = Folders(self._client) + """Template folder listing - where a usable ``folder_id`` comes from.""" + self.audience = Audience(self._client) """Audience management — lists, contacts, topics, properties, segments.""" diff --git a/src/lettr/_client.py b/src/lettr/_client.py index 36e4867..c5aa77b 100644 --- a/src/lettr/_client.py +++ b/src/lettr/_client.py @@ -46,38 +46,72 @@ def request( *, json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> Any: """Send an HTTP request and return the decoded JSON body. Raises the appropriate :class:`LettrError` subclass on non-2xx responses. """ + body, _ = self.request_with_headers(method, path, json=json, params=params, headers=headers) + return body + + def request_with_headers( + self, + method: str, + path: str, + *, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> tuple[Any, httpx.Headers]: + """Like :meth:`request`, but also returns the response headers. + + Separate rather than state on the client, so two calls cannot read each + other's headers. Only needed where a header carries meaning - today + that is ``Idempotency-Replayed`` on a send. + """ # Strip None values from params if params: params = {k: v for k, v in params.items() if v is not None} try: - response = self._http.request(method, path, json=json, params=params) + response = self._http.request(method, path, json=json, params=params, headers=headers) except httpx.HTTPError as exc: raise LettrError(f"HTTP request failed: {exc}") from exc if response.status_code == 204: - return None + return None, response.headers try: body = response.json() except Exception: - raise_for_status(response.status_code, None) - return None + raise_for_status(response.status_code, None, response.headers) + return None, response.headers - raise_for_status(response.status_code, body) - return body + raise_for_status(response.status_code, body, response.headers) + return body, response.headers def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: return self.request("GET", path, params=params) - def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any: - return self.request("POST", path, json=json) + def post( + self, + path: str, + *, + json: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + return self.request("POST", path, json=json, headers=headers) + + def post_with_headers( + self, + path: str, + *, + json: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> tuple[Any, httpx.Headers]: + return self.request_with_headers("POST", path, json=json, headers=headers) def put(self, path: str, *, json: dict[str, Any] | None = None) -> Any: return self.request("PUT", path, json=json) diff --git a/src/lettr/_exceptions.py b/src/lettr/_exceptions.py index 1272e50..eb1404f 100644 --- a/src/lettr/_exceptions.py +++ b/src/lettr/_exceptions.py @@ -88,6 +88,42 @@ def __init__( super().__init__(message, error_code) +class IdempotencyConflictError(ConflictError): + """The ``Idempotency-Key`` was already used with a *different* payload. + + HTTP 409, ``error_code="idempotency_key_conflict"``. + + **Never retry this.** Two different emails were sent under one key, which is + a bug on the caller's side; the same request will fail identically forever. + Use a key that is unique per logical send, or send the payload the key was + first used with. + + Keys are scoped per team *and* API key, so the same string sent through a + different API key is a different key and will not collide. + """ + + +class IdempotencyInProgressError(ConflictError): + """The original send for this ``Idempotency-Key`` is still processing. + + HTTP 409, ``error_code="idempotency_in_progress"``. + + Unlike :class:`IdempotencyConflictError` this **is** retryable, and must be + retried with the *same* key - a fresh key would send a second email. Wait + :attr:`retry_after` seconds first. + """ + + def __init__( + self, + message: str, + error_code: str | None = None, + retry_after: int | None = None, + ) -> None: + self.retry_after = retry_after + """Seconds to wait before retrying, from the ``Retry-After`` header.""" + super().__init__(message, error_code) + + class BadRequestError(LettrError): """Raised for client-side errors (400).""" @@ -112,8 +148,16 @@ def __init__(self, message: str, error_code: str | None = None) -> None: super().__init__(message) -def raise_for_status(status_code: int, body: Any) -> None: - """Raise the appropriate exception based on the HTTP status code.""" +def raise_for_status( + status_code: int, + body: Any, + headers: Any | None = None, +) -> None: + """Raise the appropriate exception based on the HTTP status code. + + ``headers`` is only consulted for ``Retry-After``, which is what separates + the retryable idempotency conflict from the permanent one. + """ if 200 <= status_code < 300: return @@ -136,6 +180,18 @@ def raise_for_status(status_code: int, body: Any) -> None: raise NotFoundError(message=message, error_code=error_code) if status_code == 409: + # The two idempotency conflicts need telling apart: one is safe to + # retry with the same key, the other will fail forever. + if error_code == "idempotency_in_progress": + raise IdempotencyInProgressError( + message=message, + error_code=error_code, + retry_after=_retry_after(headers), + ) + + if error_code == "idempotency_key_conflict": + raise IdempotencyConflictError(message=message, error_code=error_code) + raise ConflictError(message=message, error_code=error_code) if status_code == 422: @@ -148,3 +204,24 @@ def raise_for_status(status_code: int, body: Any) -> None: raise ServerError(message=message, error_code=error_code) raise LettrError(f"{message} (HTTP {status_code})") + + +def _retry_after(headers: Any | None) -> int | None: + """Parse ``Retry-After`` into seconds, or None when absent or unusable.""" + if headers is None: + return None + + try: + value = headers.get("Retry-After") + except AttributeError: + return None + + if value is None: + return None + + try: + seconds = int(value) + except (TypeError, ValueError): + return None + + return seconds if seconds > 0 else None diff --git a/src/lettr/_idempotency.py b/src/lettr/_idempotency.py new file mode 100644 index 0000000..f58b4e9 --- /dev/null +++ b/src/lettr/_idempotency.py @@ -0,0 +1,45 @@ +"""Idempotency key validation. + +The key identifies one logical send. Reuse it when you retry and the API +returns the original result instead of delivering a second email. + +**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 that two calls are the +same logical send. A key generated inside ``send()`` would differ on every +attempt and protect nothing while looking like it did. +""" + +from __future__ import annotations + +import re + +from ._exceptions import ValidationError + +IDEMPOTENCY_KEY_PATTERN = re.compile(r"\A[A-Za-z0-9._-]{1,255}\Z") +"""The format the API accepts: 1-255 characters of letters, digits, ``.``, ``_``, ``-``.""" + + +def is_valid_idempotency_key(key: str) -> bool: + """Whether a string is a usable idempotency key. + + Exported so callers deriving keys from their own ids - an order number, a + job id - can check before sending rather than discovering it as a 422. + """ + return IDEMPOTENCY_KEY_PATTERN.match(key) is not None + + +def validate_idempotency_key(key: str) -> None: + """Raise :class:`ValidationError` if the key is malformed. + + Checked here so a bad key fails locally instead of costing a round trip. + """ + if is_valid_idempotency_key(key): + return + + raise ValidationError( + message="Validation failed.", + errors={ + "Idempotency-Key": ["Use 1 to 255 letters, digits, periods, underscores or hyphens."] + }, + ) diff --git a/src/lettr/_types.py b/src/lettr/_types.py index 076914b..ca23bb3 100644 --- a/src/lettr/_types.py +++ b/src/lettr/_types.py @@ -151,6 +151,11 @@ class SendEmailResponse: request_id: str accepted: int rejected: int + replayed: bool = False + """True when this replayed an earlier send under the same idempotency key. + + No second email went out. It is still a success, not an error. + """ @dataclass @@ -418,6 +423,26 @@ class MergeTag: children: list[MergeTagChild] | None = None +TemplatePurpose = Literal["transactional", "campaign"] +"""Which module a template belongs to. + +The two do not mix: only ``campaign`` templates can be picked by the campaign +builder, and only ``transactional`` ones can be sent as single emails. +""" + +TemplatePreparationStatus = Literal["pending", "ready", "failed"] +"""How far a template has got through preparation. + +Creating or updating a template through the API defers image migration and HTML +rendering to a background job. On a create with JSON there is no HTML at all +until it finishes; on an **update** the previous render stays in place, so the +template is still sendable but is serving the *old* content. + +So ``"ready"`` answers "is what I sent what will go out", which is not the same +question as "can I send this". +""" + + @dataclass class Template: """An email template.""" @@ -434,6 +459,37 @@ class Template: html: str | None = None json: str | None = None merge_tags: list[MergeTag] | None = None + purpose: TemplatePurpose = "transactional" + preparation_status: TemplatePreparationStatus = "ready" + + +@dataclass +class Folder: + """A folder templates are filed into. + + ``id`` is what :meth:`Templates.create` takes as ``folder_id``, so listing + folders is how a caller picks where a template lands instead of hardcoding + an integer read out of an app URL. + """ + + id: int + name: str + project_id: int + purpose: TemplatePurpose + templates_count: int + created_at: str + updated_at: str + + +@dataclass +class FolderList: + """Paginated list of folders.""" + + folders: list[Folder] + total: int + per_page: int + current_page: int + last_page: int @dataclass diff --git a/src/lettr/resources/__init__.py b/src/lettr/resources/__init__.py index 4705c53..bf900a6 100644 --- a/src/lettr/resources/__init__.py +++ b/src/lettr/resources/__init__.py @@ -4,8 +4,18 @@ from .campaigns import Campaigns from .domains import Domains from .emails import Emails +from .folders import Folders from .projects import Projects from .templates import Templates from .webhooks import Webhooks -__all__ = ["Audience", "Campaigns", "Domains", "Emails", "Projects", "Templates", "Webhooks"] +__all__ = [ + "Audience", + "Campaigns", + "Domains", + "Emails", + "Folders", + "Projects", + "Templates", + "Webhooks", +] diff --git a/src/lettr/resources/emails.py b/src/lettr/resources/emails.py index 11ce946..214796f 100644 --- a/src/lettr/resources/emails.py +++ b/src/lettr/resources/emails.py @@ -5,6 +5,7 @@ from typing import Any, Sequence from .._client import ApiClient +from .._idempotency import validate_idempotency_key from .._types import ( Attachment, Email, @@ -238,6 +239,7 @@ def send( substitution_data: dict[str, Any] | None = None, options: EmailOptions | None = None, attachments: Sequence[Attachment] | None = None, + idempotency_key: str | None = None, ) -> SendEmailResponse: """Send a transactional email. @@ -265,16 +267,33 @@ def send( substitution_data: Variables for template substitution. options: Delivery options (tracking, etc.). attachments: File attachments. + idempotency_key: A key identifying one logical send. Reuse it when + you retry and the API returns the original result instead of + delivering a second email, with ``replayed`` set. + + 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. + + 1-255 characters of ``[A-Za-z0-9._-]``, validated before the + request. Returns: A :class:`SendEmailResponse` with ``request_id``, ``accepted``, and ``rejected`` counts. Raises: - ValidationError: If required fields are missing or invalid. + ValidationError: If required fields are missing or invalid, or + ``idempotency_key`` is malformed - raised locally, before any + request goes out. BadRequestError: If the sender domain is invalid or unconfigured. NotFoundError: If the template or project is not found. RateLimitError: If sending quota is exceeded. + IdempotencyInProgressError: If the original send for this key is + still processing. Retry with the **same** key. + IdempotencyConflictError: If this key was used with a different + payload. A caller bug - do not retry. """ payload = _build_email_payload( from_email=from_email, @@ -299,12 +318,31 @@ def send( attachments=attachments, ) - body = self._client.post("/emails", json=payload) + # Without a key there is nothing to replay - SparkPost only + # deduplicates when one is present - so the plain path skips reading + # response headers entirely. + if idempotency_key is None: + body = self._client.post("/emails", json=payload) + data = body["data"] + return SendEmailResponse( + request_id=data["request_id"], + accepted=data["accepted"], + rejected=data["rejected"], + ) + + validate_idempotency_key(idempotency_key) + + body, response_headers = self._client.post_with_headers( + "/emails", + json=payload, + headers={"Idempotency-Key": idempotency_key}, + ) data = body["data"] return SendEmailResponse( request_id=data["request_id"], accepted=data["accepted"], rejected=data["rejected"], + replayed=(response_headers.get("Idempotency-Replayed") or "").lower() == "true", ) def list( diff --git a/src/lettr/resources/folders.py b/src/lettr/resources/folders.py new file mode 100644 index 0000000..9a3e1b0 --- /dev/null +++ b/src/lettr/resources/folders.py @@ -0,0 +1,92 @@ +"""Template folder listing.""" + +from __future__ import annotations + +from typing import Any + +from .._client import ApiClient +from .._types import Folder, FolderList, TemplatePurpose + + +class Folders: + """Read-only operations for template folders. + + Creating, renaming and deleting folders stay in the app, because deleting + one moves or deletes the templates inside it. + + Usage:: + + folders = client.folders.list(purpose="campaign") + client.templates.create( + name="October Newsletter", + json=topol_json, + folder_id=folders.folders[0].id, + purpose="campaign", + ) + """ + + def __init__(self, client: ApiClient) -> None: + self._client = client + + def list( + self, + *, + project_id: int | None = None, + purpose: TemplatePurpose | None = None, + per_page: int | None = None, + page: int | None = None, + ) -> FolderList: + """List the folders templates are filed into. + + This is what :meth:`Templates.create`'s ``folder_id`` was missing: + nothing else returns 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. + + Args: + project_id: Project to list folders from. Without one the team's + default project is used, as ``templates.list()`` does. + purpose: Narrow to one module. Omit for both. + per_page: Results per page (1-100, default 25). + page: Page number (default 1). + + Returns: + A :class:`FolderList` with folders and pagination info. + + Raises: + NotFoundError: If the project is not found or belongs to another team. + """ + params: dict[str, Any] = {} + if project_id is not None: + params["project_id"] = project_id + if purpose is not None: + params["purpose"] = purpose + if per_page is not None: + params["per_page"] = per_page + if page is not None: + params["page"] = page + + body = self._client.get("/folders", params=params) + data = body["data"] + pagination = data["pagination"] + + folders = [ + Folder( + id=f["id"], + name=f["name"], + project_id=f["project_id"], + purpose=f.get("purpose", "transactional"), + templates_count=f.get("templates_count", 0), + created_at=f["created_at"], + updated_at=f["updated_at"], + ) + for f in data["folders"] + ] + + return FolderList( + folders=folders, + total=pagination["total"], + per_page=pagination["per_page"], + current_page=pagination["current_page"], + last_page=pagination["last_page"], + ) diff --git a/src/lettr/resources/templates.py b/src/lettr/resources/templates.py index 2aa6c72..ff6b0ae 100644 --- a/src/lettr/resources/templates.py +++ b/src/lettr/resources/templates.py @@ -12,6 +12,7 @@ TemplateHtml, TemplateList, TemplateMergeTags, + TemplatePurpose, ) @@ -52,6 +53,8 @@ def list( self, *, project_id: int | None = None, + folder_id: int | None = None, + purpose: TemplatePurpose | None = None, per_page: int | None = None, page: int | None = None, ) -> TemplateList: @@ -60,15 +63,30 @@ def list( Args: project_id: Project ID to retrieve templates from. If not provided, uses the team's default project. + folder_id: Narrow the list to one folder of that project. Discover + ids with :meth:`Folders.list`. 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. + purpose: Narrow the list to one module. Omit for both. per_page: Results per page (1-100, default 25). page: Page number (default 1). Returns: A :class:`TemplateList` with templates and pagination info. + + Raises: + NotFoundError: If the project is not found, or ``folder_id`` is not + in it - a folder outside the project is a 404 rather than an + empty list, so a typo cannot be misread as "nothing is there". """ params: dict[str, Any] = {} if project_id is not None: params["project_id"] = project_id + if folder_id is not None: + params["folder_id"] = folder_id + if purpose is not None: + params["purpose"] = purpose if per_page is not None: params["per_page"] = per_page if page is not None: @@ -85,6 +103,8 @@ def list( slug=t["slug"], project_id=t["project_id"], folder_id=t["folder_id"], + purpose=t.get("purpose", "transactional"), + preparation_status=t.get("preparation_status", "ready"), created_at=t["created_at"], updated_at=t["updated_at"], ) @@ -124,6 +144,8 @@ def get(self, slug: str, *, project_id: int | None = None) -> Template: slug=d["slug"], project_id=d["project_id"], folder_id=d["folder_id"], + purpose=d.get("purpose", "transactional"), + preparation_status=d.get("preparation_status", "ready"), created_at=d["created_at"], updated_at=d.get("updated_at"), active_version=d.get("active_version"), @@ -140,6 +162,7 @@ def create( json: str | None = None, project_id: int | None = None, folder_id: int | None = None, + purpose: TemplatePurpose | None = None, ) -> Template: """Create a new email template. @@ -151,7 +174,11 @@ def create( html: HTML content. Mutually exclusive with ``json``. json: Topol JSON content. Mutually exclusive with ``html``. project_id: Project to create the template in. - folder_id: Folder to create the template in. + folder_id: Folder to create the template in. Must belong to the + same module as ``purpose``. Discover ids with + :meth:`Folders.list`. + purpose: Which module the template belongs to. Omit to let the API + decide, which today means ``"transactional"``. Returns: A :class:`Template` with the newly created template info. @@ -169,6 +196,8 @@ def create( payload["project_id"] = project_id if folder_id is not None: payload["folder_id"] = folder_id + if purpose is not None: + payload["purpose"] = purpose body = self._client.post("/templates", json=payload) d = body["data"] @@ -183,6 +212,8 @@ def create( slug=d["slug"], project_id=d["project_id"], folder_id=d["folder_id"], + purpose=d.get("purpose", "transactional"), + preparation_status=d.get("preparation_status", "ready"), active_version=d.get("active_version"), merge_tags=merge_tags, created_at=d["created_at"], @@ -239,6 +270,8 @@ def update( slug=d["slug"], project_id=d["project_id"], folder_id=d["folder_id"], + purpose=d.get("purpose", "transactional"), + preparation_status=d.get("preparation_status", "ready"), active_version=d.get("active_version"), merge_tags=merge_tags, created_at=d["created_at"], diff --git a/tests/test_folders.py b/tests/test_folders.py new file mode 100644 index 0000000..2dae1b4 --- /dev/null +++ b/tests/test_folders.py @@ -0,0 +1,102 @@ +"""Tests for the folders resource.""" + +from __future__ import annotations + +from lettr import Lettr +from lettr._types import Folder, FolderList +from lettr.resources.folders import Folders + + +def folder_payload(folder_id: int, name: str, purpose: str) -> dict: + return { + "id": folder_id, + "name": name, + "project_id": 5, + "purpose": purpose, + "templates_count": 12, + "created_at": "2026-01-15T10:00:00+00:00", + "updated_at": "2026-01-20T14:30:00+00:00", + } + + +class TestList: + def test_returns_folders_and_pagination(self, mock_client) -> None: + mock_client.get.return_value = { + "data": { + "folders": [ + folder_payload(10, "Emails", "transactional"), + folder_payload(11, "Campaigns", "campaign"), + ], + "pagination": { + "total": 2, + "per_page": 25, + "current_page": 1, + "last_page": 1, + }, + } + } + + result = Folders(mock_client).list() + + assert isinstance(result, FolderList) + assert [f.name for f in result.folders] == ["Emails", "Campaigns"] + assert result.folders[1].purpose == "campaign" + assert result.folders[0].templates_count == 12 + assert result.total == 2 + mock_client.get.assert_called_once_with("/folders", params={}) + + def test_sends_every_filter(self, mock_client) -> None: + mock_client.get.return_value = { + "data": { + "folders": [], + "pagination": { + "total": 0, + "per_page": 50, + "current_page": 2, + "last_page": 2, + }, + } + } + + Folders(mock_client).list(project_id=5, purpose="campaign", per_page=50, page=2) + + mock_client.get.assert_called_once_with( + "/folders", + params={ + "project_id": 5, + "purpose": "campaign", + "per_page": 50, + "page": 2, + }, + ) + + def test_defaults_a_folder_without_purpose_or_count(self, mock_client) -> None: + """An API deployment that predates the fields should still parse.""" + mock_client.get.return_value = { + "data": { + "folders": [ + { + "id": 10, + "name": "Emails", + "project_id": 5, + "created_at": "2026-01-15T10:00:00+00:00", + "updated_at": "2026-01-20T14:30:00+00:00", + } + ], + "pagination": { + "total": 1, + "per_page": 25, + "current_page": 1, + "last_page": 1, + }, + } + } + + folder = Folders(mock_client).list().folders[0] + + assert isinstance(folder, Folder) + assert folder.purpose == "transactional" + assert folder.templates_count == 0 + + def test_is_reachable_from_the_client(self) -> None: + assert isinstance(Lettr("test-key").folders, Folders) diff --git a/tests/test_idempotency.py b/tests/test_idempotency.py new file mode 100644 index 0000000..5c51aec --- /dev/null +++ b/tests/test_idempotency.py @@ -0,0 +1,172 @@ +"""Tests for idempotent sends.""" + +from __future__ import annotations + +import httpx +import pytest + +from lettr._exceptions import ( + ConflictError, + IdempotencyConflictError, + IdempotencyInProgressError, + ValidationError, + raise_for_status, +) +from lettr._idempotency import is_valid_idempotency_key, validate_idempotency_key +from lettr.resources.emails import Emails + +ACCEPTED = {"data": {"request_id": "req-1", "accepted": 1, "rejected": 0}} + + +def send(client, **kwargs): + return Emails(client).send( + from_email="sender@example.com", + to=["recipient@example.com"], + subject="Hello", + html="

Hi

", + **kwargs, + ) + + +class TestKeyValidation: + @pytest.mark.parametrize( + "key", + ["order-confirmation-12345", "a.b_c-1", "a", "a" * 255], + ) + def test_accepts_the_documented_format(self, key: str) -> None: + assert is_valid_idempotency_key(key) + + @pytest.mark.parametrize( + "key", + ["", "order 123", "order/123", "order:123", "order-č", "a" * 256], + ) + def test_rejects_anything_else(self, key: str) -> None: + assert not is_valid_idempotency_key(key) + + with pytest.raises(ValidationError): + validate_idempotency_key(key) + + def test_a_bad_key_never_reaches_the_api(self, mock_client) -> None: + """Validated locally, so it costs no round trip and no 422.""" + with pytest.raises(ValidationError): + send(mock_client, idempotency_key="order 123") + + mock_client.post.assert_not_called() + mock_client.post_with_headers.assert_not_called() + + +class TestSendingTheKey: + def test_puts_the_key_in_the_header_not_the_body(self, mock_client) -> None: + mock_client.post_with_headers.return_value = (ACCEPTED, httpx.Headers({})) + + send(mock_client, idempotency_key="order-12345") + + _, kwargs = mock_client.post_with_headers.call_args + assert kwargs["headers"] == {"Idempotency-Key": "order-12345"} + assert "idempotency_key" not in kwargs["json"] + + def test_no_key_means_no_header(self, mock_client) -> None: + """The compatibility guarantee: an existing caller's request is unchanged.""" + mock_client.post.return_value = ACCEPTED + + result = send(mock_client) + + mock_client.post.assert_called_once() + mock_client.post_with_headers.assert_not_called() + # Nothing to replay without a key, so it is definitionally False. + assert result.replayed is False + + +class TestReadingTheAnswer: + def test_a_replay_is_a_success_that_says_so(self, mock_client) -> None: + mock_client.post_with_headers.return_value = ( + ACCEPTED, + httpx.Headers({"Idempotency-Replayed": "true"}), + ) + + result = send(mock_client, idempotency_key="order-12345") + + # No second email went out, but nothing failed either. + assert result.replayed is True + assert result.accepted == 1 + + def test_reads_the_header_case_insensitively(self, mock_client) -> None: + mock_client.post_with_headers.return_value = ( + ACCEPTED, + httpx.Headers({"idempotency-replayed": "TRUE"}), + ) + + assert send(mock_client, idempotency_key="order-12345").replayed is True + + def test_a_normal_send_is_not_replayed(self, mock_client) -> None: + mock_client.post_with_headers.return_value = (ACCEPTED, httpx.Headers({})) + + assert send(mock_client, idempotency_key="order-12345").replayed is False + + +class TestTheTwoConflicts: + """One is safe to retry with the same key; the other fails forever.""" + + def test_in_progress_is_retryable_and_carries_retry_after(self) -> None: + with pytest.raises(IdempotencyInProgressError) as exc: + raise_for_status( + 409, + { + "message": "A request with this Idempotency-Key is still processing.", + "error_code": "idempotency_in_progress", + }, + httpx.Headers({"Retry-After": "3"}), + ) + + assert exc.value.retry_after == 3 + assert isinstance(exc.value, ConflictError) + + def test_in_progress_without_retry_after(self) -> None: + with pytest.raises(IdempotencyInProgressError) as exc: + raise_for_status( + 409, + {"message": "still processing", "error_code": "idempotency_in_progress"}, + httpx.Headers({}), + ) + + assert exc.value.retry_after is None + + def test_a_payload_conflict_must_not_be_retried(self) -> None: + with pytest.raises(IdempotencyConflictError) as exc: + raise_for_status( + 409, + { + "message": ( + "This Idempotency-Key was already used with a different request payload." + ), + "error_code": "idempotency_key_conflict", + }, + httpx.Headers({}), + ) + + assert isinstance(exc.value, ConflictError) + assert not hasattr(exc.value, "retry_after") + + def test_an_unrelated_409_stays_a_plain_conflict(self) -> None: + with pytest.raises(ConflictError) as exc: + raise_for_status( + 409, + { + "message": "A contact with this email already exists.", + "error_code": "resource_already_exists", + }, + httpx.Headers({}), + ) + + assert not isinstance(exc.value, IdempotencyConflictError) + assert not isinstance(exc.value, IdempotencyInProgressError) + + def test_raise_for_status_still_works_without_headers(self) -> None: + """The headers argument is optional, so existing callers are unaffected.""" + with pytest.raises(IdempotencyInProgressError) as exc: + raise_for_status( + 409, + {"message": "still processing", "error_code": "idempotency_in_progress"}, + ) + + assert exc.value.retry_after is None diff --git a/tests/test_templates.py b/tests/test_templates.py index 5e63cd8..75e2e2e 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -191,3 +191,159 @@ def test_get_html_empty_merge_tags(self, templates: Templates, mock_client: Magi assert result.html == "

Hello

" assert result.merge_tags == [] assert result.subject is None + + +class TestPreparationStatusAndFolderFilter: + """Added in 1.6.0 - TPL-2543.""" + + def test_list_sends_the_folder_and_purpose_filters(self, mock_client) -> None: + mock_client.get.return_value = { + "data": { + "templates": [], + "pagination": { + "total": 0, + "per_page": 100, + "current_page": 1, + "last_page": 1, + }, + } + } + + Templates(mock_client).list(folder_id=10, purpose="campaign", per_page=100) + + mock_client.get.assert_called_once_with( + "/templates", + params={"folder_id": 10, "purpose": "campaign", "per_page": 100}, + ) + + def test_list_omits_the_new_params_when_unset(self, mock_client) -> None: + """An existing caller's request is unchanged.""" + mock_client.get.return_value = { + "data": { + "templates": [], + "pagination": { + "total": 0, + "per_page": 25, + "current_page": 1, + "last_page": 1, + }, + } + } + + Templates(mock_client).list(project_id=5) + + mock_client.get.assert_called_once_with("/templates", params={"project_id": 5}) + + def test_list_reads_the_preparation_status_of_every_row(self, mock_client) -> None: + mock_client.get.return_value = { + "data": { + "templates": [ + { + "id": 1, + "name": "Ready", + "slug": "ready-one", + "project_id": 5, + "folder_id": 10, + "purpose": "transactional", + "preparation_status": "ready", + "created_at": "2026-01-15T10:00:00+00:00", + "updated_at": "2026-01-20T14:30:00+00:00", + }, + { + "id": 2, + "name": "Working", + "slug": "still-working", + "project_id": 5, + "folder_id": 10, + "purpose": "campaign", + "preparation_status": "pending", + "created_at": "2026-01-15T10:00:00+00:00", + "updated_at": "2026-01-20T14:30:00+00:00", + }, + ], + "pagination": { + "total": 2, + "per_page": 25, + "current_page": 1, + "last_page": 1, + }, + } + } + + templates = Templates(mock_client).list().templates + + assert [t.preparation_status for t in templates] == ["ready", "pending"] + assert [t.purpose for t in templates] == ["transactional", "campaign"] + + def test_a_response_without_the_fields_reads_as_ready(self, mock_client) -> None: + """An API deployment that predates the field had every template with + HTML simply usable, so `ready` is the honest default. `pending` would + look like a stalled queue and hang anything waiting for readiness.""" + mock_client.get.return_value = { + "data": { + "templates": [ + { + "id": 1, + "name": "Legacy", + "slug": "legacy", + "project_id": 5, + "folder_id": 10, + "created_at": "2026-01-15T10:00:00+00:00", + "updated_at": "2026-01-20T14:30:00+00:00", + } + ], + "pagination": { + "total": 1, + "per_page": 25, + "current_page": 1, + "last_page": 1, + }, + } + } + + template = Templates(mock_client).list().templates[0] + + assert template.preparation_status == "ready" + assert template.purpose == "transactional" + + def test_create_sends_the_purpose_only_when_given(self, mock_client) -> None: + mock_client.post.return_value = { + "data": { + "id": 1, + "name": "October Newsletter", + "slug": "october-newsletter", + "project_id": 5, + "folder_id": 11, + "purpose": "campaign", + "preparation_status": "pending", + "active_version": 1, + "created_at": "2026-01-15T10:00:00+00:00", + } + } + + result = Templates(mock_client).create( + name="October Newsletter", json="{}", purpose="campaign" + ) + + _, kwargs = mock_client.post.call_args + assert kwargs["json"]["purpose"] == "campaign" + assert result.purpose == "campaign" + # A JSON import has no HTML until the background job renders it. + assert result.preparation_status == "pending" + + def test_create_omits_purpose_when_unset(self, mock_client) -> None: + mock_client.post.return_value = { + "data": { + "id": 1, + "name": "Welcome", + "slug": "welcome", + "project_id": 5, + "folder_id": 10, + "created_at": "2026-01-15T10:00:00+00:00", + } + } + + Templates(mock_client).create(name="Welcome", html="

Hi

") + + _, kwargs = mock_client.post.call_args + assert "purpose" not in kwargs["json"]