diff --git a/README.md b/README.md index d690ecd..cf02882 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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` | diff --git a/src/lettermint/_async/client.py b/src/lettermint/_async/client.py index 76ca6ad..1715fd3 100644 --- a/src/lettermint/_async/client.py +++ b/src/lettermint/_async/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import AsyncIterator from types import TracebackType from typing import Any, cast @@ -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. @@ -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( diff --git a/src/lettermint/_core.py b/src/lettermint/_core.py index f2d8020..e5d19ab 100644 --- a/src/lettermint/_core.py +++ b/src/lettermint/_core.py @@ -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) diff --git a/src/lettermint/_generated/__init__.py b/src/lettermint/_generated/__init__.py index b603cdf..206ba2d 100644 --- a/src/lettermint/_generated/__init__.py +++ b/src/lettermint/_generated/__init__.py @@ -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.""" diff --git a/src/lettermint/_generated/operations.py b/src/lettermint/_generated/operations.py index 88785a1..afb3e33 100644 --- a/src/lettermint/_generated/operations.py +++ b/src/lettermint/_generated/operations.py @@ -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 ``" "``. @@ -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, ), @@ -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, ), diff --git a/src/lettermint/_generated/types.py b/src/lettermint/_generated/types.py index 766dd4f..59a3bcb 100644 --- a/src/lettermint/_generated/types.py +++ b/src/lettermint/_generated/types.py @@ -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. @@ -281,7 +281,6 @@ "enhanced_status_category", "bounce_classification", "failure_reason", - "smtp_response_group", "engagement_state", "request_actor", "proxy_type", @@ -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]], }, ) @@ -357,7 +356,7 @@ AnalyticsFilterOperator: TypeAlias = Literal["eq", "in", "not_in", "is_null", "is_not_null"] | str -# A catalogue dimension or tag: 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: 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 @@ -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], }, @@ -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], @@ -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], }, ) @@ -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]], diff --git a/src/lettermint/_sync/client.py b/src/lettermint/_sync/client.py index 65f56a5..bd537fb 100644 --- a/src/lettermint/_sync/client.py +++ b/src/lettermint/_sync/client.py @@ -3,6 +3,7 @@ from __future__ import annotations +from collections.abc import Iterator from types import TracebackType from typing import Any, cast @@ -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. @@ -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( diff --git a/src/lettermint/exceptions.py b/src/lettermint/exceptions.py index dce8808..fe1e4ed 100644 --- a/src/lettermint/exceptions.py +++ b/src/lettermint/exceptions.py @@ -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. diff --git a/tests/fixtures/analytics-page-1.json b/tests/fixtures/analytics-page-1.json new file mode 100644 index 0000000..eba87a8 --- /dev/null +++ b/tests/fixtures/analytics-page-1.json @@ -0,0 +1,140 @@ +{ + "data": { + "summary": { + "metrics": { + "delivered": 1200, + "bounced": 0, + "delivery_rate": 0.9836, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 1200, + "denominator": 1220 + } + }, + "previous": { + "metrics": { + "delivered": 1100, + "bounced": 4, + "delivery_rate": null, + "delivery_latency_p50_ms": 812.5 + }, + "rate_bases": { + "delivery_rate": { + "numerator": null, + "denominator": null + } + } + }, + "change": { + "delivered": { + "absolute": 100, + "relative": 0.0909 + }, + "delivery_rate": { + "absolute": null, + "relative": null, + "percentage_points": null + } + } + }, + "time_series": [ + { + "from": "2026-09-15T08:30:00+05:30", + "to": "2026-09-15T09:30:00+05:30", + "available": true, + "partial": false, + "metrics": { + "delivered": 1200, + "bounced": 0, + "delivery_rate": 0.9836, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 1200, + "denominator": 1220 + } + } + }, + { + "from": "2026-09-15T09:30:00+05:30", + "to": "2026-09-15T10:30:00+05:30", + "available": false, + "partial": true, + "metrics": { + "delivered": null, + "bounced": null, + "delivery_rate": null, + "delivery_latency_p50_ms": null + }, + "rate_bases": {} + } + ], + "breakdown": [ + { + "dimensions": { + "recipient_domain": "gmail.com" + }, + "metrics": { + "delivered": 900, + "bounced": 0, + "delivery_rate": 0.989, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 900, + "denominator": 910 + } + } + }, + { + "dimensions": { + "recipient_domain": "outlook.com" + }, + "metrics": { + "delivered": 250, + "bounced": 0, + "delivery_rate": 0.9615, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 250, + "denominator": 260 + } + } + } + ] + }, + "meta": { + "time_basis": "event", + "timezone": "Asia/Kolkata", + "interval": "hour", + "from": "2026-09-15T03:00:00.000000Z", + "to": "2026-09-15T05:00:00.000000Z", + "effective_to": "2026-09-15T05:00:00.000000Z", + "alignment": "hour", + "generated_at": "2026-09-15T04:12:30.482915Z", + "available_since": "2026-06-17T00:00:00.000000Z", + "partial": true, + "ongoing": true, + "collection_completeness": "best_effort", + "last_ingested_at": null, + "metric_definition_version": "analytics-v2", + "ranked_group_limit": 1000, + "comparison": { + "from": "2026-09-15T01:00:00.000000Z", + "to": "2026-09-15T03:00:00.000000Z", + "partial": false + } + }, + "pagination": { + "total_groups": 3, + "returned_groups": 2, + "next_cursor": "cursor-page-2", + "truncated": false + } +} diff --git a/tests/fixtures/analytics-page-2.json b/tests/fixtures/analytics-page-2.json new file mode 100644 index 0000000..e133309 --- /dev/null +++ b/tests/fixtures/analytics-page-2.json @@ -0,0 +1,118 @@ +{ + "data": { + "summary": { + "metrics": { + "delivered": 1200, + "bounced": 0, + "delivery_rate": 0.9836, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 1200, + "denominator": 1220 + } + }, + "previous": { + "metrics": { + "delivered": 1100, + "bounced": 4, + "delivery_rate": null, + "delivery_latency_p50_ms": 812.5 + }, + "rate_bases": { + "delivery_rate": { + "numerator": null, + "denominator": null + } + } + }, + "change": { + "delivered": { + "absolute": 100, + "relative": 0.0909 + }, + "delivery_rate": { + "absolute": null, + "relative": null, + "percentage_points": null + } + } + }, + "time_series": [ + { + "from": "2026-09-15T08:30:00+05:30", + "to": "2026-09-15T09:30:00+05:30", + "available": true, + "partial": false, + "metrics": { + "delivered": 1200, + "bounced": 0, + "delivery_rate": 0.9836, + "delivery_latency_p50_ms": null + }, + "rate_bases": { + "delivery_rate": { + "numerator": 1200, + "denominator": 1220 + } + } + }, + { + "from": "2026-09-15T09:30:00+05:30", + "to": "2026-09-15T10:30:00+05:30", + "available": false, + "partial": true, + "metrics": { + "delivered": null, + "bounced": null, + "delivery_rate": null, + "delivery_latency_p50_ms": null + }, + "rate_bases": {} + } + ], + "breakdown": [ + { + "dimensions": { + "recipient_domain": null + }, + "metrics": { + "delivered": 50, + "bounced": null, + "delivery_rate": null, + "delivery_latency_p50_ms": null + }, + "rate_bases": {} + } + ] + }, + "meta": { + "time_basis": "event", + "timezone": "Asia/Kolkata", + "interval": "hour", + "from": "2026-09-15T03:00:00.000000Z", + "to": "2026-09-15T05:00:00.000000Z", + "effective_to": "2026-09-15T05:00:00.000000Z", + "alignment": "hour", + "generated_at": "2026-09-15T04:12:30.482915Z", + "available_since": "2026-06-17T00:00:00.000000Z", + "partial": true, + "ongoing": true, + "collection_completeness": "best_effort", + "last_ingested_at": null, + "metric_definition_version": "analytics-v2", + "ranked_group_limit": 1000, + "comparison": { + "from": "2026-09-15T01:00:00.000000Z", + "to": "2026-09-15T03:00:00.000000Z", + "partial": false + } + }, + "pagination": { + "total_groups": 3, + "returned_groups": 1, + "next_cursor": null, + "truncated": false + } +} diff --git a/tests/fixtures/analytics-summary.json b/tests/fixtures/analytics-summary.json new file mode 100644 index 0000000..f8fb3db --- /dev/null +++ b/tests/fixtures/analytics-summary.json @@ -0,0 +1,33 @@ +{ + "data": { + "summary": { + "metrics": { + "delivered": 1200 + }, + "rate_bases": {} + } + }, + "meta": { + "time_basis": "event", + "timezone": "UTC", + "interval": "day", + "from": "2026-09-15T03:00:00.000000Z", + "to": "2026-09-15T05:00:00.000000Z", + "effective_to": "2026-09-15T05:00:00.000000Z", + "alignment": "day", + "generated_at": "2026-09-15T04:12:30.482915Z", + "available_since": "2026-06-17T00:00:00.000000Z", + "partial": true, + "ongoing": true, + "collection_completeness": "best_effort", + "last_ingested_at": null, + "metric_definition_version": "analytics-v2", + "ranked_group_limit": 1000 + }, + "pagination": { + "total_groups": 0, + "returned_groups": 0, + "next_cursor": null, + "truncated": false + } +} diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..1b21abf --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,313 @@ +"""Analytics: realistic responses, ``analytics_pages`` and the errors of ``POST /analytics``.""" + +from __future__ import annotations + +import copy +import json +from collections.abc import AsyncIterator, Iterator +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from lettermint import ( + AsyncLettermint, + Lettermint, + LettermintConfigError, + ServerError, + ValidationError, +) +from lettermint.types import AnalyticsQuery, AnalyticsResponse + +from .conftest import BASE_URL, SENDING_TOKEN, MockAPI + +FIXTURES = Path(__file__).parent / "fixtures" + + +def fixture(name: str) -> Any: + return json.loads((FIXTURES / f"{name}.json").read_text()) + + +PAGE_ONE = fixture("analytics-page-1") +PAGE_TWO = fixture("analytics-page-2") +SUMMARY_ONLY = fixture("analytics-summary") + +QUERY: AnalyticsQuery = { + "metrics": ["delivered", "bounced", "delivery_rate", "delivery_latency_p50_ms"], + "include": ["summary", "time_series", "breakdown"], + "group_by": ["recipient_domain"], + "interval": "hour", + "timezone": "Asia/Kolkata", + "compare": "previous_period", + "limit": 2, +} +WITH_CURSOR: AnalyticsQuery = {**QUERY, "cursor": "cursor-page-2"} +EXPIRED = { + "message": "The analytics cursor is invalid or expired. Submit a new query.", + "errors": {"cursor": ["The analytics cursor is invalid or expired. Submit a new query."]}, +} + + +def first_then(api: MockAPI, first: httpx.Response, later: httpx.Response) -> None: + api.respond(lambda request: first if len(api.requests) == 1 else later) + + +# ---------------------------------------------------------------- responses + + +def test_analytics_keeps_null_values_empty_rate_bases_and_both_timestamp_formats( + api: MockAPI, client: Lettermint +) -> None: + api.json(200, PAGE_ONE) + result = client.analytics(QUERY) + + assert (api.last.method, api.last.url.path) == ("POST", "/v1/analytics") + assert api.body() == QUERY + summary = result["data"]["summary"] + assert summary["metrics"] == { + "delivered": 1200, + "bounced": 0, + "delivery_rate": 0.9836, + "delivery_latency_p50_ms": None, + } + assert summary["rate_bases"]["delivery_rate"] == {"numerator": 1200, "denominator": 1220} + assert summary["previous"]["rate_bases"]["delivery_rate"] == { + "numerator": None, + "denominator": None, + } + assert summary["change"]["delivery_rate"] == { + "absolute": None, + "relative": None, + "percentage_points": None, + } + assert "percentage_points" not in summary["change"]["delivered"] + + complete, unavailable = result["data"]["time_series"] + assert complete["from"] == "2026-09-15T08:30:00+05:30" + assert datetime.fromisoformat(complete["from"]) == datetime( + 2026, 9, 15, 3, 0, tzinfo=timezone.utc + ) + assert (unavailable["available"], unavailable["partial"]) == (False, True) + assert unavailable["rate_bases"] == {} + assert unavailable["metrics"]["delivered"] is None + + meta = result["meta"] + assert meta["generated_at"] == "2026-09-15T04:12:30.482915Z" + assert meta["from"] == "2026-09-15T03:00:00.000000Z" + assert meta["last_ingested_at"] is None + assert meta["comparison"] == { + "from": "2026-09-15T01:00:00.000000Z", + "to": "2026-09-15T03:00:00.000000Z", + "partial": False, + } + assert result["pagination"] == { + "total_groups": 3, + "returned_groups": 2, + "next_cursor": "cursor-page-2", + "truncated": False, + } + + +def test_analytics_leaves_out_the_sections_and_comparison_a_query_did_not_ask_for( + api: MockAPI, client: Lettermint +) -> None: + api.json(200, SUMMARY_ONLY) + result = client.analytics({"metrics": ["delivered"]}) + assert result["data"]["summary"]["rate_bases"] == {} + assert "time_series" not in result["data"] + assert "breakdown" not in result["data"] + assert "previous" not in result["data"]["summary"] + assert "comparison" not in result["meta"] + assert result["pagination"]["next_cursor"] is None + + +def test_analytics_keeps_a_null_dimension_value_in_a_breakdown_row( + api: MockAPI, client: Lettermint +) -> None: + api.json(200, PAGE_TWO) + result = client.analytics(WITH_CURSOR) + breakdown = result["data"]["breakdown"] + assert len(breakdown) == 1 + assert breakdown[0]["dimensions"] == {"recipient_domain": None} + assert breakdown[0]["metrics"]["bounced"] is None + + +# ---------------------------------------------------------------- analytics_pages + + +def test_analytics_pages_follows_next_cursor_and_yields_every_response( + api: MockAPI, client: Lettermint +) -> None: + sent = copy.deepcopy(QUERY) + first_then(api, httpx.Response(200, json=PAGE_ONE), httpx.Response(200, json=PAGE_TWO)) + pages = list(client.analytics_pages(sent)) + + assert pages == [PAGE_ONE, PAGE_TWO] + assert [(r.method, r.url.path) for r in api.requests] == [("POST", "/v1/analytics")] * 2 + assert api.body(0) == QUERY + assert api.body(1) == WITH_CURSOR + assert sent == QUERY + + rows = [row for page in pages for row in page["data"].get("breakdown", [])] + assert [row["dimensions"]["recipient_domain"] for row in rows] == [ + "gmail.com", + "outlook.com", + None, + ] + assert (pages[1]["pagination"]["returned_groups"], pages[1]["pagination"]["next_cursor"]) == ( + 1, + None, + ) + + +def test_analytics_pages_makes_one_request_for_a_query_without_more_pages( + api: MockAPI, client: Lettermint +) -> None: + api.json(200, SUMMARY_ONLY) + assert list(client.analytics_pages({"metrics": ["delivered"]})) == [SUMMARY_ONLY] + assert len(api.requests) == 1 + + +def test_analytics_pages_requests_the_next_page_only_when_it_is_asked_for( + api: MockAPI, client: Lettermint +) -> None: + api.json(200, PAGE_ONE) + pages: Iterator[AnalyticsResponse] = client.analytics_pages(QUERY) + assert api.requests == [] + for page in pages: + assert page["pagination"]["next_cursor"] == "cursor-page-2" + break + assert len(api.requests) == 1 + + +def test_analytics_pages_stops_when_the_api_repeats_a_cursor( + api: MockAPI, client: Lettermint +) -> None: + api.json(200, PAGE_ONE) + assert len(list(client.analytics_pages(QUERY))) == 2 + assert len(api.requests) == 2 + + +def test_analytics_pages_stops_when_the_api_returns_the_cursor_the_query_started_from( + api: MockAPI, client: Lettermint +) -> None: + api.json(200, PAGE_ONE) + assert len(list(client.analytics_pages(WITH_CURSOR))) == 1 + assert [api.body(index) for index in range(len(api.requests))] == [WITH_CURSOR] + + +def test_analytics_pages_passes_the_timeout_to_every_page_and_surfaces_an_expired_cursor( + api: MockAPI, client: Lettermint +) -> None: + first_then(api, httpx.Response(200, json=PAGE_ONE), httpx.Response(422, json=EXPIRED)) + pages = client.analytics_pages(QUERY, timeout=7) + assert next(pages) == PAGE_ONE + with pytest.raises(ValidationError) as caught: + next(pages) + assert caught.value.errors == EXPIRED["errors"] + assert len(api.requests) == 2 + assert all(r.extensions["timeout"]["read"] == 7 for r in api.requests) + + +def test_analytics_pages_checks_the_token_and_the_timeout_before_the_first_page( + api: MockAPI, client: Lettermint +) -> None: + with pytest.raises(LettermintConfigError, match="timeout must be a positive number"): + client.analytics_pages(QUERY, timeout=-1) + sending_only = Lettermint(SENDING_TOKEN, base_url=BASE_URL, http_client=api.sync_client()) + with pytest.raises(LettermintConfigError, match="analytics_pages needs team_token"): + sending_only.analytics_pages(QUERY) + assert api.requests == [] + + +@pytest.mark.anyio +async def test_async_analytics_pages_follows_next_cursor_and_yields_every_response( + api: MockAPI, aclient: AsyncLettermint +) -> None: + sent = copy.deepcopy(QUERY) + first_then(api, httpx.Response(200, json=PAGE_ONE), httpx.Response(200, json=PAGE_TWO)) + pages = [page async for page in aclient.analytics_pages(sent)] + assert pages == [PAGE_ONE, PAGE_TWO] + assert [api.body(0), api.body(1)] == [QUERY, WITH_CURSOR] + assert sent == QUERY + + +@pytest.mark.anyio +async def test_async_analytics_pages_is_lazy_and_stops_on_a_repeated_cursor( + api: MockAPI, aclient: AsyncLettermint +) -> None: + api.json(200, PAGE_ONE) + pages: AsyncIterator[AnalyticsResponse] = aclient.analytics_pages(QUERY) + assert api.requests == [] + assert (await anext(pages))["pagination"]["next_cursor"] == "cursor-page-2" + assert len(api.requests) == 1 + assert [page async for page in pages] == [PAGE_ONE] + assert len(api.requests) == 2 + assert [page async for page in aclient.analytics_pages(WITH_CURSOR)] == [PAGE_ONE] + assert len(api.requests) == 3 + + +@pytest.mark.anyio +async def test_async_analytics_pages_passes_the_timeout_and_surfaces_an_expired_cursor( + api: MockAPI, aclient: AsyncLettermint +) -> None: + first_then(api, httpx.Response(200, json=PAGE_ONE), httpx.Response(422, json=EXPIRED)) + pages = aclient.analytics_pages(QUERY, timeout=7) + assert await anext(pages) == PAGE_ONE + with pytest.raises(ValidationError) as caught: + await anext(pages) + assert caught.value.errors == EXPIRED["errors"] + assert all(r.extensions["timeout"]["read"] == 7 for r in api.requests) + with pytest.raises(LettermintConfigError, match="timeout must be a positive number"): + aclient.analytics_pages(QUERY, timeout=-1) + assert len(api.requests) == 2 + + +# ---------------------------------------------------------------- errors + + +def test_analytics_reads_field_errors_from_a_422(api: MockAPI, client: Lettermint) -> None: + body = { + "message": "smtp_response_group can only be used in group_by.", + "errors": {"filters": ["smtp_response_group can only be used in group_by."]}, + } + api.json(422, body) + with pytest.raises(ValidationError) as caught: + client.analytics(QUERY) + assert (caught.value.status, caught.value.message, caught.value.errors) == ( + 422, + body["message"], + body["errors"], + ) + + +def test_analytics_reads_retry_after_from_a_503(api: MockAPI, client: Lettermint) -> None: + body = {"error": {"code": "SERVICE_UNAVAILABLE", "message": "Try again shortly."}} + api.json(503, body, **{"Retry-After": "2"}) + with pytest.raises(ServerError) as caught: + client.analytics(QUERY) + assert (caught.value.status, caught.value.code, caught.value.retry_after) == ( + 503, + "SERVICE_UNAVAILABLE", + 2, + ) + + +def test_analytics_has_no_retry_after_for_a_503_or_504_without_the_header( + api: MockAPI, client: Lettermint +) -> None: + api.json(503, {"message": "Analytics is unavailable."}) + with pytest.raises(ServerError) as unavailable: + client.analytics(QUERY) + assert unavailable.value.retry_after is None + + message = ( + "Analytics exceeded the query time limit. Retry with a shorter period or fewer dimensions." + ) + api.json(504, {"message": message}) + with pytest.raises(ServerError) as timed_out: + client.analytics(QUERY) + assert (timed_out.value.status, timed_out.value.message) == (504, message) + assert timed_out.value.retry_after is None diff --git a/tests/test_surface.py b/tests/test_surface.py index 8fd8f9e..50f37bb 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -22,7 +22,12 @@ # (method path, positional arguments, operation key) CALLS: list[tuple[str, tuple[Any, ...], str]] = [ ("ping", (), "GET /ping"), - ("analytics", ({"from": "2026-10-01", "to": "2026-10-31"},), "POST /analytics"), + ( + "analytics", + ({"metrics": ["delivered"], "from": "2026-10-01", "to": "2026-10-31"},), + "POST /analytics", + ), + ("analytics_pages", ({"metrics": ["delivered"]},), "POST /analytics"), ("blocked_file_types", (), "GET /blocked-file-types"), ("emails.send", (MESSAGE,), "POST /send"), ("emails.send_batch", ([MESSAGE],), "POST /send/batch"), @@ -186,7 +191,7 @@ ], "webhooks.deliveries": ["iterate", "list", "retrieve"], "emails": ["compose", "ping", "send", "send_batch"], - "": ["analytics", "blocked_file_types", "close", "ping"], + "": ["analytics", "analytics_pages", "blocked_file_types", "close", "ping"], } @@ -231,7 +236,7 @@ def test_every_operation_is_reachable_from_the_sync_client( before = len(api.requests) result = resolve(client, dotted)(*args) if isinstance(result, Iterator): - assert list(result) == [{"id": "x"}] + assert list(result) == [{} if dotted == "analytics_pages" else {"id": "x"}] assert [operation_of(r) for r in api.requests[before:]] == [key], dotted reached.add(key) assert reached == set(OPERATIONS) @@ -247,7 +252,9 @@ async def test_every_operation_is_reachable_from_the_async_client( before = len(api.requests) result = resolve(aclient, dotted)(*args) if isinstance(result, AsyncIterator): - assert [item async for item in result] == [{"id": "x"}] + assert [item async for item in result] == [ + {} if dotted == "analytics_pages" else {"id": "x"} + ] else: await result assert [operation_of(r) for r in api.requests[before:]] == [key], dotted @@ -282,7 +289,7 @@ def test_sync_and_async_clients_have_the_same_signatures() -> None: inspect.signature(right) ).replace("Async", ""), (group, name) assert inspect.iscoroutinefunction(right) != ( - name in ("compose", "iterate", "iterate_events") + name in ("compose", "iterate", "iterate_events", "analytics_pages") ), (group, name) builder_methods = _public_methods(sync.emails.compose()) assert builder_methods == _public_methods(asynchronous.emails.compose()) @@ -302,6 +309,8 @@ def test_return_annotations_match_the_operation_table() -> None: assert typing.get_args(returned) == (getattr(lm_types, operation.pagination.items),), ( dotted ) + elif dotted == "analytics_pages": + assert typing.get_args(returned) == (getattr(lm_types, operation.response.type),) elif operation.response.type == "text": assert returned is str, dotted elif operation.response.type == "empty": diff --git a/tests/test_transport.py b/tests/test_transport.py index fdfd24c..e324f5d 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -305,6 +305,22 @@ def test_retry_after_seconds_and_http_dates(api: MockAPI, client: Lettermint) -> assert unparsable.value.retry_after is None +def test_retry_after_on_a_5xx_response(api: MockAPI, client: Lettermint) -> None: + api.json(503, {"message": "Service Unavailable"}, **{"Retry-After": "2"}) + with pytest.raises(ServerError) as caught: + client.emails.send(MESSAGE) + assert caught.value.retry_after == 2 + later = email.utils.formatdate(time.time() + 120, usegmt=True) + api.json(502, {}, **{"Retry-After": later}) + with pytest.raises(ServerError) as dated: + client.emails.send(MESSAGE) + assert dated.value.retry_after is not None and 115 <= dated.value.retry_after <= 121 + api.json(500, {"message": "Server Error"}) + with pytest.raises(ServerError) as plain: + client.emails.send(MESSAGE) + assert plain.value.retry_after is None + + def test_errors_survive_pickling(api: MockAPI, client: Lettermint) -> None: import pickle @@ -318,6 +334,11 @@ def test_errors_survive_pickling(api: MockAPI, client: Lettermint) -> None: 3, "slow down", ) + api.json(503, {"message": "down"}, **{"Retry-After": "5"}) + with pytest.raises(ServerError) as server: + client.emails.send(MESSAGE) + restored = pickle.loads(pickle.dumps(server.value)) + assert (type(restored), restored.status, restored.retry_after) == (ServerError, 503, 5) # ---------------------------------------------------------------- redirects