Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
26 changes: 25 additions & 1 deletion src/lettr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
ConflictError,
ContactAlreadyExistsError,
ForbiddenError,
IdempotencyConflictError,
IdempotencyInProgressError,
LettrError,
NotFoundError,
RateLimitError,
Expand Down Expand Up @@ -73,6 +75,8 @@
EmailEventList,
EmailList,
EmailOptions,
Folder,
FolderList,
GeoIp,
HealthCheck,
MergeTag,
Expand All @@ -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
Expand All @@ -105,6 +120,8 @@
"BadRequestError",
"ConflictError",
"ContactAlreadyExistsError",
"IdempotencyConflictError",
"IdempotencyInProgressError",
"ForbiddenError",
"NotFoundError",
"RateLimitError",
Expand Down Expand Up @@ -159,12 +176,16 @@
"HealthCheck",
"MergeTag",
"MergeTagChild",
"Folder",
"FolderList",
"Project",
"ProjectList",
"ScheduledEmail",
"SendEmailResponse",
"SpfValidationResult",
"Template",
"TemplatePreparationStatus",
"TemplatePurpose",
"TemplateHtml",
"TemplateList",
"TemplateMergeTags",
Expand Down Expand Up @@ -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."""

Expand Down
50 changes: 42 additions & 8 deletions src/lettr/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
81 changes: 79 additions & 2 deletions src/lettr/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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
45 changes: 45 additions & 0 deletions src/lettr/_idempotency.py
Original file line number Diff line number Diff line change
@@ -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."]
},
)
Loading
Loading