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
49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ html = lettermint.messages.html("message-id")
| `team.members` | `list`, `iterate`, `retrieve`, `update_assignment` |
| `webhooks` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerate_secret` |
| `webhooks.deliveries` | `list(webhook_id)`, `iterate(webhook_id)`, `retrieve(webhook_id, delivery_id)` |
| (root) | `ping`, `analytics`, `blocked_file_types` |
| (root) | `ping`, `analytics`, `analytics_pages`, `blocked_file_types` |

### Query parameters and pagination

Expand All @@ -296,6 +296,51 @@ async for delivery in async_lettermint.webhooks.deliveries.iterate(webhook_id):
...
```

### Analytics

`lettermint.analytics(query)` runs one analytics query. `metrics` is the only required field; by default the API returns a summary of the last 30 days:

```python
result = lettermint.analytics({
"metrics": ["delivered", "bounced", "delivery_rate"],
"from": "2026-10-01",
"to": "2026-10-31",
"timezone": "Europe/Amsterdam",
})

print(result["data"]["summary"]["metrics"]["delivery_rate"]) # 0.9836, or None when there is no data
print(result["meta"]["partial"], result["meta"]["effective_to"])
```

Add `include` to ask for a `time_series` or a `breakdown`. A breakdown needs `group_by`, and the API returns its rows in pages of `limit` (at most 200). `analytics_pages()` follows `pagination.next_cursor` for you. It returns an iterator (an async iterator on `AsyncLettermint`) that yields one whole response per request, so each page keeps its `meta` and `pagination`:

```python
from lettermint.types import AnalyticsBreakdownRow, AnalyticsQuery

query: AnalyticsQuery = {
"metrics": ["delivered", "bounced"],
"include": ["breakdown"],
"group_by": ["recipient_domain"],
"sort": {"metric": "bounced", "direction": "desc"},
"limit": 200,
}

rows: list[AnalyticsBreakdownRow] = []
for page in lettermint.analytics_pages(query):
rows.extend(page["data"].get("breakdown", []))
if page["pagination"]["truncated"]:
print("More groups exist than the API ranks.")
```

A cursor expires 60 seconds after its response, so read the next page promptly. An expired cursor raises a `ValidationError` with `errors["cursor"]`; run the query again to start over.

A few things to know when you read a response:

- A metric is `None` when the API cannot measure it for that row or bucket, and a rate is `None` when its denominator is zero. `0` means a measured zero.
- `data["summary"]`, `data["time_series"]` and `data["breakdown"]` are present only when `include` asks for them. `previous`, `change` and `meta["comparison"]` are present only with `compare`.
- `smtp_response_group` can be used in `group_by` but not as a filter dimension.
- Analytics can answer `503` or `504` when a query takes too long or the service is busy. Both raise a `ServerError`; see [Errors](#errors).

### Timeouts and cancellation

Every method takes a keyword-only `timeout` in seconds that overrides the client's. With `AsyncLettermint`, cancelling the task (for example with `asyncio.timeout()` or a `trio` cancel scope) cancels the request; the SDK lets the cancellation through.
Expand All @@ -313,7 +358,7 @@ Every exception the SDK raises is a `LettermintError`:
| `ConflictError` | 409 | |
| `ValidationError` | 422 | `errors` (field errors) |
| `RateLimitError` | 429 | `retry_after` (seconds) |
| `ServerError` | 5xx | |
| `ServerError` | 5xx | `retry_after` (seconds, when the API sent `Retry-After`) |
| `APITimeoutError` | No complete response within the timeout | `timeout` |
| `APIConnectionError` | The request failed (DNS, TLS, refused, reset) | |
| `UnexpectedResponseError` | An empty or non-JSON body where JSON was expected, or an error page such as a proxy's HTML 502 | `status`, `body_excerpt` |
Expand Down
42 changes: 42 additions & 0 deletions src/lettermint/_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import AsyncIterator
from types import TracebackType
from typing import Any, cast

Expand All @@ -27,6 +28,28 @@
__all__ = ["AsyncLettermint"]


async def _analytics_pages(
transport: AsyncTransport, query: AnalyticsQuery, timeout: float | None
) -> AsyncIterator[AnalyticsResponse]:
"""Follows ``pagination.next_cursor`` until it is ``None``, or repeats a cursor."""
seen: set[str] = set()
start = query.get("cursor")
if isinstance(start, str):
seen.add(start)
current = query
while True:
page = await transport.request(
"POST /analytics", label="analytics_pages", body=current, timeout=timeout
)
yield cast(AnalyticsResponse, page)
pagination = page.get("pagination") if isinstance(page, dict) else None
cursor = pagination.get("next_cursor") if isinstance(pagination, dict) else None
if not isinstance(cursor, str) or not cursor or cursor in seen:
return
seen.add(cursor)
current = {**query, "cursor": cursor}


class AsyncLettermint:
"""The Lettermint client.

Expand Down Expand Up @@ -123,6 +146,25 @@ async def analytics(
),
)

def analytics_pages(
self, query: AnalyticsQuery, *, timeout: float | None = None
) -> AsyncIterator[AnalyticsResponse]:
"""Queries email analytics and follows ``pagination.next_cursor``. Needs ``team_token``.

Iterates over whole responses, one per request. Each response carries
the next page of ``data["breakdown"]`` with its own ``meta`` and
``pagination``.

A cursor expires 60 seconds after its response, so request the next
page promptly; an expired cursor raises
:class:`~lettermint.ValidationError`. The query passed in is not changed.
"""
# Check the token, the query and the timeout now, not at the first page.
self._transport.prepare(
"POST /analytics", label="analytics_pages", body=query, timeout=timeout
)
return _analytics_pages(self._transport, query, timeout)

async def blocked_file_types(self, *, timeout: float | None = None) -> BlockedFileTypes:
"""The file extensions and MIME types that cannot be attached. Needs ``team_token``."""
return cast(
Expand Down
4 changes: 3 additions & 1 deletion src/lettermint/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,9 @@ def _api_error(status: int, reason: str, headers: Mapping[str, str], body: Any)
message, retry_after=parse_retry_after(headers.get("retry-after")), **common
)
if status >= 500:
return ServerError(message, **common)
return ServerError(
message, retry_after=parse_retry_after(headers.get("retry-after")), **common
)
return APIError(message, **common)


Expand Down
6 changes: 3 additions & 3 deletions src/lettermint/_generated/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Generated by lettermint/sdk-generator — do not edit.
# Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
# sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
# team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
# Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
# sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
# team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
# Naming profile: next
"""Generated from the Lettermint OpenAPI specifications. Do not edit; regenerate."""
10 changes: 5 additions & 5 deletions src/lettermint/_generated/operations.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Generated by lettermint/sdk-generator — do not edit.
# Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
# sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
# team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
# Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
# sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
# team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
# Naming profile: next
"""Every operation of the Sending and Team APIs, keyed by ``"<METHOD> <path>"``.

Expand Down Expand Up @@ -605,7 +605,7 @@ class Operation:
headers=(),
request=RequestBody(type="AnalyticsQuery", content_type="application/json", required=True),
response=ResponseBody(status=(200,), content_type="application/json", type="AnalyticsResponse"),
errors={400: ("ErrorMessage",), 401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 415: ("ErrorMessage",), 422: ("ErrorMessage",), 429: ("ErrorMessage", "ApiErrorBody")},
errors={400: ("ErrorMessage",), 401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 415: ("ErrorMessage",), 422: ("ErrorMessage",), 429: ("ErrorMessage", "ApiErrorBody"), 503: ("ErrorMessage", "ApiErrorBody"), 504: ("ErrorMessage",)},
pagination=None,
deprecated=False,
),
Expand Down Expand Up @@ -669,7 +669,7 @@ class Operation:
headers=(),
request=None,
response=ResponseBody(status=(200,), content_type="application/json", type="ScheduledMessage"),
errors={401: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("ApiErrorBody", "ErrorMessage"), 422: ("ErrorMessage",)},
errors={401: ("ApiErrorBody", "ErrorMessage"), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("ApiErrorBody", "ErrorMessage"), 422: ("ErrorMessage",)},
pagination=None,
deprecated=False,
),
Expand Down
21 changes: 12 additions & 9 deletions src/lettermint/_generated/types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Generated by lettermint/sdk-generator — do not edit.
# Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main)
# sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f
# team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd
# Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main)
# sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d
# team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138
# Naming profile: next
"""Request and response types of the Lettermint API.

Expand Down Expand Up @@ -281,7 +281,6 @@
"enhanced_status_category",
"bounce_classification",
"failure_reason",
"smtp_response_group",
"engagement_state",
"request_actor",
"proxy_type",
Expand Down Expand Up @@ -347,7 +346,7 @@
"dimension": Required["AnalyticsDimension"],
# eq requires one value; in and not_in require one or more values; is_null and is_not_null require no values.
"operator": Required["AnalyticsFilterOperator"],
# String values to match. Supply one for eq, one or more for in/not_in, and none for null checks.
# String values to match. Supply one for eq, one or more for in/not_in, and none for null checks. project_id and route_id take UUIDs; smtp_code takes a three-digit reply code.
"values": NotRequired[list[str]],
},
)
Expand All @@ -357,7 +356,7 @@
AnalyticsFilterOperator: TypeAlias = Literal["eq", "in", "not_in", "is_null", "is_not_null"] | str


# A catalogue dimension or tag:<case-sensitive name> to group breakdown rows by. Engagement class dimensions (is_human, is_privacy, is_bot and is_scanner) can only filter.
# A catalogue dimension or tag:<case-sensitive name> to group breakdown rows by. Engagement class dimensions (is_human, is_privacy, is_bot and is_scanner) can only filter. smtp_response_group can only group: each row reads the SMTP reply of the group as its reply code, enhanced status code and response text, with addresses and other identifiers replaced by placeholders.
AnalyticsGroupDimension: TypeAlias = AnalyticsCatalogueGroupDimension | str


Expand Down Expand Up @@ -1243,6 +1242,7 @@ class CursorPage(TypedDict, Generic[T]):
"event": Required["MessageEventType"],
"tag": Required[str | None],
"tags": Required["list[MessageTag]"],
# Event-specific metadata. For `opened` and `clicked` events, `reason_codes` and `engagement.reason_codes` list broad categories behind the bot classification: `privacy_proxy`, `image_proxy`, `link_protection`, `known_bot`, `security_network`, `timing`, `link_pattern`, `request_shape`, `unconfirmed`, `confirmed`, `sandbox` or `other`. New categories can be added. Use `classification` and `counts_for_metrics` for decisions
"metadata": Required[dict[str, Any] | None],
"timestamp": Required[str],
},
Expand All @@ -1267,7 +1267,7 @@ class CursorPage(TypedDict, Generic[T]):
ListMessagesQueryFilter = TypedDict(
"ListMessagesQueryFilter",
{
# Search in sender, recipients, and subject
# Search in sender, recipients, and subject. A complete email address matches the sender, To, CC or BCC address exactly and does not search the subject.
"search": NotRequired[str],
# Search by sender email (partial match)
"from_email": NotRequired[str],
Expand All @@ -1286,9 +1286,9 @@ class CursorPage(TypedDict, Generic[T]):
"domain_id": NotRequired[str],
# Filter by tag name
"tag": NotRequired[str],
# Filter messages created after this date
# Filter messages created on or after this date
"from_date": NotRequired[str],
# Filter messages created before this date
# Filter messages created on or before this date
"to_date": NotRequired[str],
},
)
Expand Down Expand Up @@ -2299,6 +2299,9 @@ class CursorPage(TypedDict, Generic[T]):
# Custom headers to include in the email. To preserve a submitted RFC Message-ID,
# include both Message-ID and X-LM-Preserve-Message-ID with a string value of true or 1.
# Header names and control values are case-insensitive; false or 0 retains replacement.
# Send values as plain text: Lettermint encodes non-ASCII characters and repairs small formatting
# errors, such as line breaks, control characters and missing angle brackets. Header names must
# be printable ASCII without a colon.
"headers": NotRequired[dict[str, str]],
# Metadata to track with the email (not added as email headers).
"metadata": NotRequired[dict[str, str]],
Expand Down
42 changes: 42 additions & 0 deletions src/lettermint/_sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

from collections.abc import Iterator
from types import TracebackType
from typing import Any, cast

Expand All @@ -28,6 +29,28 @@
__all__ = ["Lettermint"]


def _analytics_pages(
transport: Transport, query: AnalyticsQuery, timeout: float | None
) -> Iterator[AnalyticsResponse]:
"""Follows ``pagination.next_cursor`` until it is ``None``, or repeats a cursor."""
seen: set[str] = set()
start = query.get("cursor")
if isinstance(start, str):
seen.add(start)
current = query
while True:
page = transport.request(
"POST /analytics", label="analytics_pages", body=current, timeout=timeout
)
yield cast(AnalyticsResponse, page)
pagination = page.get("pagination") if isinstance(page, dict) else None
cursor = pagination.get("next_cursor") if isinstance(pagination, dict) else None
if not isinstance(cursor, str) or not cursor or cursor in seen:
return
seen.add(cursor)
current = {**query, "cursor": cursor}


class Lettermint:
"""The Lettermint client.

Expand Down Expand Up @@ -124,6 +147,25 @@ def analytics(
),
)

def analytics_pages(
self, query: AnalyticsQuery, *, timeout: float | None = None
) -> Iterator[AnalyticsResponse]:
"""Queries email analytics and follows ``pagination.next_cursor``. Needs ``team_token``.

Iterates over whole responses, one per request. Each response carries
the next page of ``data["breakdown"]`` with its own ``meta`` and
``pagination``.

A cursor expires 60 seconds after its response, so request the next
page promptly; an expired cursor raises
:class:`~lettermint.ValidationError`. The query passed in is not changed.
"""
# Check the token, the query and the timeout now, not at the first page.
self._transport.prepare(
"POST /analytics", label="analytics_pages", body=query, timeout=timeout
)
return _analytics_pages(self._transport, query, timeout)

def blocked_file_types(self, *, timeout: float | None = None) -> BlockedFileTypes:
"""The file extensions and MIME types that cannot be attached. Needs ``team_token``."""
return cast(
Expand Down
14 changes: 14 additions & 0 deletions src/lettermint/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ def __init__(
class ServerError(APIError):
"""HTTP 5xx with a JSON or empty body."""

def __init__(
self,
message: str,
*,
status: int,
code: str | None = None,
details: Any = None,
body: Any = None,
retry_after: int | None = None,
) -> None:
super().__init__(message, status=status, code=code, details=details, body=body)
#: Seconds to wait, from the ``Retry-After`` header, when the API sent one.
self.retry_after = retry_after


class APITimeoutError(LettermintError):
"""The request did not complete within the timeout.
Expand Down
Loading
Loading