From 15929893285aeb2725e8403a38e61bacbd1c5d31 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sat, 3 Oct 2026 13:48:37 +0200 Subject: [PATCH 1/8] fix(packaging): declare typing_extensions for all Python versions types.py imports typing_extensions unconditionally, but the dependency was only declared for Python < 3.11. Installs on 3.11+ only worked when another package happened to pull it in transitively. --- pyproject.toml | 2 +- tests/test_packaging.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/test_packaging.py diff --git a/pyproject.toml b/pyproject.toml index b8865d5..3fabba4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ ] dependencies = [ "httpx>=0.27", - "typing_extensions>=4.0; python_version < '3.11'", + "typing_extensions>=4.0", ] [project.optional-dependencies] diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..8743cf2 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,27 @@ +"""Packaging checks for the declared runtime dependencies.""" + +import re +from importlib.metadata import requires +from pathlib import Path + +import lettermint + + +def test_typing_extensions_declared_for_all_python_versions() -> None: + """Modules import typing_extensions unconditionally, so it must be an unconditional dependency.""" + package_dir = Path(lettermint.__file__).parent + imports_typing_extensions = any( + re.search(r"^\s*(from|import) typing_extensions\b", path.read_text(), re.MULTILINE) + for path in package_dir.rglob("*.py") + ) + assert imports_typing_extensions + + declared = requires("lettermint") or [] + unconditional = [ + requirement + for requirement in declared + if re.match(r"typing[-_]extensions\b", requirement, re.IGNORECASE) + and "extra ==" not in requirement + and ";" not in requirement + ] + assert unconditional, f"typing_extensions must be an unconditional dependency, got {declared}" From 0c410a8366658b9748a6fde6dff1a9f8006211bc Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sat, 3 Oct 2026 13:48:37 +0200 Subject: [PATCH 2/8] fix(webhook): verify bytes payloads and reject non-ASCII signatures Bytes payloads (e.g. Django's request.body) were interpolated into an f-string, producing "t.b'...'" and always failing verification. The HMAC is now computed over the raw bytes. Non-ASCII signature header values no longer raise TypeError from hmac.compare_digest and fail as InvalidSignatureError. Non-UTF-8 bodies raise JsonDecodeError. --- src/lettermint/webhook.py | 24 ++++++----- tests/test_webhook.py | 86 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/lettermint/webhook.py b/src/lettermint/webhook.py index e5a54a0..5a3588d 100644 --- a/src/lettermint/webhook.py +++ b/src/lettermint/webhook.py @@ -49,14 +49,14 @@ def __init__(self, secret: str, tolerance: int = DEFAULT_TOLERANCE) -> None: def verify( self, - payload: str, + payload: str | bytes, signature: str, timestamp: int | None = None, ) -> dict[str, Any]: """Verify a webhook signature and return the decoded payload. Args: - payload: The raw request body as a string. + payload: The raw request body as a string or bytes. Bytes are signed as-is. signature: The signature header value (format: t={timestamp},v1={hash}). timestamp: Optional timestamp from delivery header for cross-validation. @@ -86,19 +86,23 @@ def verify( self._validate_timestamp(signature_timestamp) - signed_content = f"{signature_timestamp}.{payload}" + raw_payload = payload if isinstance(payload, bytes) else payload.encode() + signed_content = f"{signature_timestamp}.".encode() + raw_payload computed_signature = hmac.new( self._secret.encode(), - signed_content.encode(), + signed_content, hashlib.sha256, ).hexdigest() - if not hmac.compare_digest(computed_signature, expected_signature): + # Compare as bytes: compare_digest raises TypeError for non-ASCII str input. + if not hmac.compare_digest( + computed_signature.encode(), expected_signature.encode("utf-8", "replace") + ): raise InvalidSignatureError("Signature verification failed") try: data: dict[str, Any] = json.loads(payload) - except json.JSONDecodeError as e: + except ValueError as e: raise JsonDecodeError(f"Failed to decode webhook payload: {e}") from e return data @@ -106,13 +110,13 @@ def verify( def verify_headers( self, headers: dict[str, str], - payload: str, + payload: str | bytes, ) -> dict[str, Any]: """Verify a webhook using HTTP headers and return the decoded payload. Args: headers: HTTP headers from the request (case-insensitive). - payload: The raw request body as a string. + payload: The raw request body as a string or bytes. Bytes are signed as-is. Returns: The decoded webhook payload as a dictionary. @@ -151,7 +155,7 @@ def verify_headers( @staticmethod def verify_signature( - payload: str, + payload: str | bytes, signature: str, secret: str, timestamp: int | None = None, @@ -160,7 +164,7 @@ def verify_signature( """Static convenience method to verify a webhook signature. Args: - payload: The raw request body as a string. + payload: The raw request body as a string or bytes. Bytes are signed as-is. signature: The signature header value (format: t={timestamp},v1={hash}). secret: The webhook signing secret. timestamp: Optional timestamp from delivery header for cross-validation. diff --git a/tests/test_webhook.py b/tests/test_webhook.py index f497ad5..07bd63a 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -251,3 +251,89 @@ def test_complex_payload(self, webhook_secret: str) -> None: result = webhook.verify(payload, signature) assert result == payload_data + + +class TestWebhookBytesPayload: + """Tests for raw bytes payloads (e.g. Django's request.body).""" + + def test_verify_bytes_payload(self, webhook_secret: str) -> None: + """Bytes payloads are verified against the raw body.""" + payload = json.dumps({"event": "email.delivered", "data": {"name": "café"}}) + signature, _ = generate_valid_signature(payload, webhook_secret) + + webhook = Webhook(secret=webhook_secret) + result = webhook.verify(payload.encode(), signature) + + assert result["event"] == "email.delivered" + assert result["data"]["name"] == "café" + + def test_verify_bytes_matches_str(self, webhook_secret: str) -> None: + """Str payloads keep working and match the bytes result.""" + payload = json.dumps({"event": "email.delivered"}) + signature, _ = generate_valid_signature(payload, webhook_secret) + + webhook = Webhook(secret=webhook_secret) + assert webhook.verify(payload, signature) == webhook.verify(payload.encode(), signature) + + def test_verify_bytes_uses_raw_body_byte_for_byte(self, webhook_secret: str) -> None: + """Whitespace and non-UTF-8-normalised bodies are signed exactly as received.""" + payload = b'{ "event" : "email.delivered" }\n' + timestamp = int(time.time()) + digest = hmac.new( + webhook_secret.encode(), f"{timestamp}.".encode() + payload, hashlib.sha256 + ).hexdigest() + + webhook = Webhook(secret=webhook_secret) + result = webhook.verify(payload, f"t={timestamp},v1={digest}") + + assert result["event"] == "email.delivered" + + def test_tampered_bytes_payload(self, webhook_secret: str) -> None: + """Tampered bytes payloads are rejected.""" + payload = json.dumps({"event": "email.delivered"}) + signature, _ = generate_valid_signature(payload, webhook_secret) + + webhook = Webhook(secret=webhook_secret) + with pytest.raises(InvalidSignatureError): + webhook.verify(json.dumps({"event": "email.bounced"}).encode(), signature) + + def test_verify_headers_bytes_payload(self, webhook_secret: str) -> None: + """verify_headers accepts a bytes payload.""" + payload = json.dumps({"event": "email.delivered"}) + signature, timestamp = generate_valid_signature(payload, webhook_secret) + headers = { + "X-Lettermint-Signature": signature, + "X-Lettermint-Delivery": str(timestamp), + } + + webhook = Webhook(secret=webhook_secret) + assert webhook.verify_headers(headers, payload.encode())["event"] == "email.delivered" + + def test_static_verify_signature_bytes_payload(self, webhook_secret: str) -> None: + """The static helper accepts a bytes payload.""" + payload = json.dumps({"event": "email.delivered"}) + signature, _ = generate_valid_signature(payload, webhook_secret) + + result = Webhook.verify_signature(payload.encode(), signature, webhook_secret) + assert result["event"] == "email.delivered" + + def test_invalid_utf8_bytes_payload_raises_json_error(self, webhook_secret: str) -> None: + """A correctly signed but non-decodable body raises JsonDecodeError.""" + payload = b"\xff\xfe not json" + timestamp = int(time.time()) + digest = hmac.new( + webhook_secret.encode(), f"{timestamp}.".encode() + payload, hashlib.sha256 + ).hexdigest() + + webhook = Webhook(secret=webhook_secret) + with pytest.raises(JsonDecodeError): + webhook.verify(payload, f"t={timestamp},v1={digest}") + + def test_non_ascii_signature_raises_verification_error(self, webhook_secret: str) -> None: + """Non-ASCII signature values fail verification instead of raising TypeError.""" + payload = json.dumps({"event": "email.delivered"}) + timestamp = int(time.time()) + + webhook = Webhook(secret=webhook_secret) + with pytest.raises(InvalidSignatureError): + webhook.verify(payload, f"t={timestamp},v1=café") From 965c5ba960793b8d9f18b1884ff3f39f4398d68b Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sat, 3 Oct 2026 23:59:21 +0200 Subject: [PATCH 3/8] build: add the generated API types and operation table from the SDK generator src/lettermint/_generated is written by the private lettermint/sdk-generator (emit python, next naming profile, spec lettermint@80d8ab2a2d). scripts/generate.py regenerates it, or with --check verifies it; without a generator checkout it verifies the generated headers only. --- examples/async_send.py | 44 - examples/basic_send.py | 23 - examples/webhook_verification.py | 80 - examples/with_attachments.py | 39 - scripts/generate.py | 104 + src/lettermint/_generated/__init__.py | 6 + src/lettermint/_generated/operations.py | 998 ++++++++ src/lettermint/_generated/types.py | 3029 +++++++++++++++++++++++ src/lettermint/_version.py | 3 + src/lettermint/client.py | 491 ---- src/lettermint/endpoints/__init__.py | 8 - src/lettermint/endpoints/api.py | 812 ------ src/lettermint/endpoints/email.py | 663 ----- src/lettermint/endpoints/endpoint.py | 52 - src/lettermint/lettermint.py | 323 --- src/lettermint/message_tag.py | 50 - src/lettermint/types.py | 2746 -------------------- tests/__init__.py | 1 - tests/conftest.py | 15 - tests/test_analytics_forwarding.py | 99 - tests/test_api_surface.py | 419 ---- tests/test_client.py | 203 -- tests/test_email.py | 487 ---- tests/test_packaging.py | 27 - tests/test_route_contract.py | 39 - tests/test_webhook.py | 339 --- tests/test_webhook_basic_auth.py | 135 - 27 files changed, 4140 insertions(+), 7095 deletions(-) delete mode 100644 examples/async_send.py delete mode 100644 examples/basic_send.py delete mode 100644 examples/webhook_verification.py delete mode 100644 examples/with_attachments.py create mode 100644 scripts/generate.py create mode 100644 src/lettermint/_generated/__init__.py create mode 100644 src/lettermint/_generated/operations.py create mode 100644 src/lettermint/_generated/types.py create mode 100644 src/lettermint/_version.py delete mode 100644 src/lettermint/client.py delete mode 100644 src/lettermint/endpoints/__init__.py delete mode 100644 src/lettermint/endpoints/api.py delete mode 100644 src/lettermint/endpoints/email.py delete mode 100644 src/lettermint/endpoints/endpoint.py delete mode 100644 src/lettermint/lettermint.py delete mode 100644 src/lettermint/message_tag.py delete mode 100644 src/lettermint/types.py delete mode 100644 tests/__init__.py delete mode 100644 tests/conftest.py delete mode 100644 tests/test_analytics_forwarding.py delete mode 100644 tests/test_api_surface.py delete mode 100644 tests/test_client.py delete mode 100644 tests/test_email.py delete mode 100644 tests/test_packaging.py delete mode 100644 tests/test_route_contract.py delete mode 100644 tests/test_webhook.py delete mode 100644 tests/test_webhook_basic_auth.py diff --git a/examples/async_send.py b/examples/async_send.py deleted file mode 100644 index 842cf8f..0000000 --- a/examples/async_send.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Async email sending example using the AsyncLettermint client. - -Useful for high-throughput applications or when integrating with -async frameworks like FastAPI, Starlette, or aiohttp. -""" - -import asyncio -import os - -from lettermint import AsyncLettermint, MessageTag - - -async def send_emails(): - # Initialize the async client - client = AsyncLettermint(os.environ["LETTERMINT_API_TOKEN"]) - - # Send multiple emails concurrently - emails = [ - {"to": "user1@example.com", "name": "Alice"}, - {"to": "user2@example.com", "name": "Bob"}, - {"to": "user3@example.com", "name": "Charlie"}, - ] - - tasks = [ - client.email() - .from_("sender@example.com") - .to(email["to"]) - .subject(f"Hello {email['name']}!") - .html(f"

Welcome aboard, {email['name']}!

") - .tags([MessageTag(name="campaign", value="onboarding")]) - .send() - for email in emails - ] - - # Send all emails concurrently - results = await asyncio.gather(*tasks) - - for result in results: - print(f"Email sent! ID: {result['id']}") - - -if __name__ == "__main__": - asyncio.run(send_emails()) diff --git a/examples/basic_send.py b/examples/basic_send.py deleted file mode 100644 index fc00d58..0000000 --- a/examples/basic_send.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Basic email sending example using the synchronous Lettermint client. -""" - -import os - -from lettermint import Lettermint, MessageTag - -# Initialize the client with your API token -client = Lettermint(os.environ["LETTERMINT_API_TOKEN"]) - -# Send a simple email using the fluent builder -response = ( - client.email() - .from_("sender@example.com") - .to("recipient@example.com") - .subject("Hello from Lettermint!") - .html("

Welcome!

This is a test email.

") - .tags([MessageTag(name="campaign", value="welcome")]) - .send() -) - -print(f"Email sent! ID: {response['id']}") diff --git a/examples/webhook_verification.py b/examples/webhook_verification.py deleted file mode 100644 index ced95d0..0000000 --- a/examples/webhook_verification.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Webhook signature verification example. - -This example shows how to verify incoming webhooks from Lettermint -to ensure they are authentic and haven't been tampered with. -""" - -import os - -from lettermint import Webhook -from lettermint.exceptions import WebhookVerificationError - -# Your webhook signing secret from the Lettermint dashboard -webhook_secret = os.environ["LETTERMINT_WEBHOOK_SECRET"] - -webhook = Webhook(webhook_secret) - - -def handle_webhook(payload: str, signature: str, timestamp: str): - """ - Handle an incoming webhook request. - - Args: - payload: The raw request body as a string - signature: The X-Lettermint-Signature header value - timestamp: The X-Lettermint-Timestamp header value - """ - try: - # Verify the webhook signature - # This will raise WebhookVerificationError if invalid - webhook.verify(payload, signature, timestamp) - - # Process the verified webhook payload - print("Webhook verified successfully!") - print(f"Payload: {payload}") - - except WebhookVerificationError as e: - print(f"Webhook verification failed: {e}") - # Return 401 Unauthorized in your web framework - - -# Example: Flask integration -""" -from flask import Flask, request - -app = Flask(__name__) - -@app.route("/webhook", methods=["POST"]) -def webhook_handler(): - payload = request.get_data(as_text=True) - signature = request.headers.get("X-Lettermint-Signature", "") - timestamp = request.headers.get("X-Lettermint-Timestamp", "") - - try: - webhook.verify(payload, signature, timestamp) - # Process the webhook... - return "OK", 200 - except WebhookVerificationError: - return "Invalid signature", 401 -""" - -# Example: FastAPI integration -""" -from fastapi import FastAPI, Request, HTTPException - -app = FastAPI() - -@app.post("/webhook") -async def webhook_handler(request: Request): - payload = await request.body() - signature = request.headers.get("X-Lettermint-Signature", "") - timestamp = request.headers.get("X-Lettermint-Timestamp", "") - - try: - webhook.verify(payload.decode(), signature, timestamp) - # Process the webhook... - return {"status": "ok"} - except WebhookVerificationError: - raise HTTPException(status_code=401, detail="Invalid signature") -""" diff --git a/examples/with_attachments.py b/examples/with_attachments.py deleted file mode 100644 index 6c30f15..0000000 --- a/examples/with_attachments.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -Email with attachments example. - -Demonstrates how to attach files to emails using base64 encoding. -""" - -import base64 -import os -from pathlib import Path - -from lettermint import Lettermint - -client = Lettermint(os.environ["LETTERMINT_API_TOKEN"]) - - -def attach_file(filepath: str) -> dict: - """Helper to create an attachment dict from a file path.""" - path = Path(filepath) - content = base64.b64encode(path.read_bytes()).decode("utf-8") - - return { - "filename": path.name, - "content": content, - } - - -# Send email with attachments -response = ( - client.email() - .from_("sender@example.com") - .to("recipient@example.com") - .subject("Monthly Report") - .html("

Please find the monthly report attached.

") - .attach(attach_file("report.pdf")) - .attach(attach_file("data.csv")) - .send() -) - -print(f"Email with attachments sent! ID: {response['id']}") diff --git a/scripts/generate.py b/scripts/generate.py new file mode 100644 index 0000000..94f82e0 --- /dev/null +++ b/scripts/generate.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Regenerate or check ``src/lettermint/_generated`` with the Lettermint SDK generator. + +The generator (``lettermint/sdk-generator``) is private. Point +``LETTERMINT_SDK_GENERATOR`` at a checkout, or keep one at ``../sdk-generator``. +The pinned specs come from GitHub (``gh api``), or from a local monorepo +checkout named by ``LETTERMINT_MONOREPO``. The generator needs Python 3.11+; +``LETTERMINT_SDK_GENERATOR_PYTHON`` names the interpreter (default: this one). + + python scripts/generate.py # regenerate from the generator's spec.next.lock.json + python scripts/generate.py --check # fail when the files differ from the generator's output + +Without a generator checkout, as in CI, ``--check`` only verifies the headers: +every generated file must start with the same generator header (spec commit, +both spec hashes, the ``next`` naming profile). +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "src" / "lettermint" / "_generated" +FILES = ("__init__.py", "types.py", "operations.py") +HEADER = [ + re.compile(r"# Generated by lettermint/sdk-generator — do not edit\.$"), + re.compile(r"# Spec: lettermint/lettermint@[0-9a-f]{40}( \(.+\))?$"), + re.compile(r"# sending-openapi\.json SHA-256: [0-9a-f]{64}$"), + re.compile(r"# team-openapi\.json SHA-256: [0-9a-f]{64}$"), + re.compile(r"# Naming profile: next$"), +] + + +def generator() -> Path | None: + configured = os.environ.get("LETTERMINT_SDK_GENERATOR") + path = Path(configured) if configured else ROOT.parent / "sdk-generator" + return path if (path / "lettermint_sdkgen").is_dir() else None + + +def check_headers() -> int: + headers = set() + for name in FILES: + path = OUT / name + if not path.exists(): + print(f"missing: {path}", file=sys.stderr) + return 1 + lines = path.read_text().splitlines()[: len(HEADER)] + if len(lines) < len(HEADER) or not all( + p.match(line) for p, line in zip(HEADER, lines, strict=True) + ): + print(f"{path} has no generator header; regenerate it", file=sys.stderr) + return 1 + headers.add("\n".join(lines)) + if len(headers) != 1: + print( + "the generated files come from different generator runs; regenerate them", + file=sys.stderr, + ) + return 1 + print("generated headers ok:\n" + headers.pop()) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) + parser.add_argument("--check", action="store_true", help="do not write; verify the files") + args = parser.parse_args(argv) + checkout = generator() + if checkout is None: + if not args.check: + print( + "No SDK generator found: set LETTERMINT_SDK_GENERATOR or check it out at ../sdk-generator", + file=sys.stderr, + ) + return 2 + print("No SDK generator checkout; checking the generated headers only.") + return check_headers() + lock = checkout / "spec.next.lock.json" + command = [ + os.environ.get("LETTERMINT_SDK_GENERATOR_PYTHON", sys.executable), + "-m", + "lettermint_sdkgen", + ] + monorepo = os.environ.get("LETTERMINT_MONOREPO") + fetch = [*command, "fetch-specs", "--lock", str(lock)] + ( + ["--monorepo", monorepo] if monorepo else [] + ) + fetched = subprocess.run(fetch, cwd=checkout) + if fetched.returncode != 0: + return fetched.returncode + emit = [*command, "emit", "python", "--profile", "next", "--lock", str(lock), "--out", str(OUT)] + result = subprocess.run(emit + (["--check"] if args.check else []), cwd=checkout) + if result.returncode != 0: + return result.returncode + return check_headers() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/lettermint/_generated/__init__.py b/src/lettermint/_generated/__init__.py new file mode 100644 index 0000000..9d72d5f --- /dev/null +++ b/src/lettermint/_generated/__init__.py @@ -0,0 +1,6 @@ +# Generated by lettermint/sdk-generator — do not edit. +# Spec: lettermint/lettermint@80d8ab2a2d73644c2bdee8a4bb9d3565aa017d72 (feat/openapi-named-schemas) +# sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f +# team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd +# 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 new file mode 100644 index 0000000..206a1c3 --- /dev/null +++ b/src/lettermint/_generated/operations.py @@ -0,0 +1,998 @@ +# Generated by lettermint/sdk-generator — do not edit. +# Spec: lettermint/lettermint@80d8ab2a2d73644c2bdee8a4bb9d3565aa017d72 (feat/openapi-named-schemas) +# sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f +# team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd +# Naming profile: next +"""Every operation of the Sending and Team APIs, keyed by ``" "``. + +Type names refer to ``types.py``. The table is data, not a client. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final, Literal, TypeAlias + +__all__ = ["OPERATIONS", "AuthSurface", "Operation", "Pagination", "RequestBody", "ResponseBody"] + +#: Which API token an operation accepts: a sending token, a team token, or either. +AuthSurface: TypeAlias = Literal["sending", "team", "either"] + + +@dataclass(frozen=True) +class RequestBody: + type: str + content_type: str + required: bool + + +@dataclass(frozen=True) +class ResponseBody: + status: tuple[int, ...] + content_type: str | None + #: A type name, ``"text"`` for a text body or ``"empty"`` for no body. + type: str + + +@dataclass(frozen=True) +class Pagination: + style: Literal["cursor"] + items: str + cursor_param: str | None + size_param: str | None + + +@dataclass(frozen=True) +class Operation: + operation_id: str + method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] + path: str + auth: AuthSurface + surfaces: tuple[Literal["sending", "team"], ...] + path_params: tuple[str, ...] + query: str | None + headers: tuple[str, ...] + request: RequestBody | None + response: ResponseBody + #: Error statuses with the type names their body may have (``"text"`` for a text body; empty for no body). + errors: Mapping[int, tuple[str, ...]] + pagination: Pagination | None + deprecated: bool + + +#: Every operation of the Sending and Team APIs, keyed by `` ``. +OPERATIONS: Final[Mapping[str, Operation]] = MappingProxyType( + { + # Delete a domain + "DELETE /domains/{domainId}": Operation( + operation_id="deleteDomain", + method="DELETE", + path="/domains/{domainId}", + auth="team", + surfaces=("team",), + path_params=("domainId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="MessageResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Delete a project + "DELETE /projects/{projectId}": Operation( + operation_id="deleteProject", + method="DELETE", + path="/projects/{projectId}", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="MessageResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Disable report forwarding + "DELETE /projects/{projectId}/report-forwarding": Operation( + operation_id="deleteReportForwarding", + method="DELETE", + path="/projects/{projectId}/report-forwarding", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(204,), content_type=None, type="empty"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ModelNotFoundException",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Delete a route + "DELETE /routes/{routeId}": Operation( + operation_id="deleteRoute", + method="DELETE", + path="/routes/{routeId}", + auth="team", + surfaces=("team",), + path_params=("routeId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="MessageResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 422: ("ApiErrorBody",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Remove email from suppression list + "DELETE /suppressions/{suppressionId}": Operation( + operation_id="deleteSuppression", + method="DELETE", + path="/suppressions/{suppressionId}", + auth="team", + surfaces=("team",), + path_params=("suppressionId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200, 202), content_type="application/json", type="DeleteSuppressionResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ModelNotFoundException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Delete a webhook + "DELETE /webhooks/{webhookId}": Operation( + operation_id="deleteWebhook", + method="DELETE", + path="/webhooks/{webhookId}", + auth="team", + surfaces=("team",), + path_params=("webhookId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="MessageResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # List blocked file types + "GET /blocked-file-types": Operation( + operation_id="listBlockedFileTypes", + method="GET", + path="/blocked-file-types", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="BlockedFileTypes"), + errors={401: ("ApiErrorBody",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # List all domains for the team + "GET /domains": Operation( + operation_id="listDomains", + method="GET", + path="/domains", + auth="team", + surfaces=("team",), + path_params=(), + query="ListDomainsQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ListDomainsResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=Pagination(style="cursor", items="DomainListData", cursor_param="page[cursor]", size_param="page[size]"), + deprecated=False, + ), + # Get domain details + "GET /domains/{domainId}": Operation( + operation_id="getDomain", + method="GET", + path="/domains/{domainId}", + auth="team", + surfaces=("team",), + path_params=("domainId",), + query="GetDomainQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="DomainData"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # List messages for the team + "GET /messages": Operation( + operation_id="listMessages", + method="GET", + path="/messages", + auth="team", + surfaces=("team",), + path_params=(), + query="ListMessagesQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ListMessagesResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 422: ("ValidationException",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=Pagination(style="cursor", items="MessageListData", cursor_param="page[cursor]", size_param="page[size]"), + deprecated=False, + ), + # Get message details + "GET /messages/{messageId}": Operation( + operation_id="getMessage", + method="GET", + path="/messages/{messageId}", + auth="team", + surfaces=("team",), + path_params=("messageId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="MessageData"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Get message events + "GET /messages/{messageId}/events": Operation( + operation_id="listMessageEvents", + method="GET", + path="/messages/{messageId}/events", + auth="team", + surfaces=("team",), + path_params=("messageId",), + query="ListMessageEventsQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ListMessageEventsResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=Pagination(style="cursor", items="MessageEventData", cursor_param="page[cursor]", size_param="page[size]"), + deprecated=False, + ), + # Get message HTML content + "GET /messages/{messageId}/html": Operation( + operation_id="getMessageHtml", + method="GET", + path="/messages/{messageId}/html", + auth="team", + surfaces=("team",), + path_params=("messageId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="text/html; charset=UTF-8", type="text"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Get message source + "GET /messages/{messageId}/source": Operation( + operation_id="getMessageSource", + method="GET", + path="/messages/{messageId}/source", + auth="team", + surfaces=("team",), + path_params=("messageId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="message/rfc822", type="text"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ErrorMessage", "ApiErrorBody"), 404: ("ErrorMessage", "ApiErrorBody"), 410: ("ErrorMessage",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Get message plain text content + "GET /messages/{messageId}/text": Operation( + operation_id="getMessageText", + method="GET", + path="/messages/{messageId}/text", + auth="team", + surfaces=("team",), + path_params=("messageId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="text/plain; charset=UTF-8", type="text"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Ping the API + "GET /ping": Operation( + operation_id="ping", + method="GET", + path="/ping", + auth="either", + surfaces=("sending", "team"), + path_params=(), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="text/html; charset=UTF-8", type="text"), + errors={401: ("ApiErrorBody", "ErrorMessage"), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # List all projects for the team + "GET /projects": Operation( + operation_id="listProjects", + method="GET", + path="/projects", + auth="team", + surfaces=("team",), + path_params=(), + query="ListProjectsQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ListProjectsResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=Pagination(style="cursor", items="ProjectListData", cursor_param="page[cursor]", size_param="page[size]"), + deprecated=False, + ), + # Get project details + "GET /projects/{projectId}": Operation( + operation_id="getProject", + method="GET", + path="/projects/{projectId}", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query="GetProjectQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ProjectData"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Get report forwarding settings + "GET /projects/{projectId}/report-forwarding": Operation( + operation_id="getReportForwarding", + method="GET", + path="/projects/{projectId}/report-forwarding", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="GetReportForwardingResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ModelNotFoundException",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # List routes for a project + "GET /projects/{projectId}/routes": Operation( + operation_id="listRoutes", + method="GET", + path="/projects/{projectId}/routes", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query="ListRoutesQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ListRoutesResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=Pagination(style="cursor", items="RouteListData", cursor_param="page[cursor]", size_param="page[size]"), + deprecated=False, + ), + # Get route details + "GET /routes/{routeId}": Operation( + operation_id="getRoute", + method="GET", + path="/routes/{routeId}", + auth="team", + surfaces=("team",), + path_params=("routeId",), + query="GetRouteQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="RouteData"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Get message statistics + "GET /stats": Operation( + operation_id="getStats", + method="GET", + path="/stats", + auth="team", + surfaces=("team",), + path_params=(), + query="GetStatsQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="StatsData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ModelNotFoundException",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # List all suppressions for the team + "GET /suppressions": Operation( + operation_id="listSuppressions", + method="GET", + path="/suppressions", + auth="team", + surfaces=("team",), + path_params=(), + query="ListSuppressionsQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ListSuppressionsResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=Pagination(style="cursor", items="SuppressedRecipientData", cursor_param="page[cursor]", size_param="page[size]"), + deprecated=False, + ), + # Get team details + "GET /team": Operation( + operation_id="getTeam", + method="GET", + path="/team", + auth="team", + surfaces=("team",), + path_params=(), + query="GetTeamQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="TeamData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Get team members + "GET /team/members": Operation( + operation_id="listTeamMembers", + method="GET", + path="/team/members", + auth="team", + surfaces=("team",), + path_params=(), + query="ListTeamMembersQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ListTeamMembersResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 409: ("RbacConflictException",), 429: ("TooManyRequests",)}, + pagination=Pagination(style="cursor", items="TeamMemberData", cursor_param="page[cursor]", size_param="page[size]"), + deprecated=False, + ), + # Get a team member + "GET /team/members/{userId}": Operation( + operation_id="getTeamMember", + method="GET", + path="/team/members/{userId}", + auth="team", + surfaces=("team",), + path_params=("userId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="TeamMemberData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("RbacConflictException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Get reusable team roles + "GET /team/roles": Operation( + operation_id="listTeamRoles", + method="GET", + path="/team/roles", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="TeamRoleListResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Get team usage statistics + "GET /team/usage": Operation( + operation_id="getTeamUsage", + method="GET", + path="/team/usage", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="TeamUsageDetailData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # List all webhooks for the team + "GET /webhooks": Operation( + operation_id="listWebhooks", + method="GET", + path="/webhooks", + auth="team", + surfaces=("team",), + path_params=(), + query="ListWebhooksQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ListWebhooksResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=Pagination(style="cursor", items="WebhookListData", cursor_param="cursor", size_param="page[size]"), + deprecated=False, + ), + # Get webhook details + "GET /webhooks/{webhookId}": Operation( + operation_id="getWebhook", + method="GET", + path="/webhooks/{webhookId}", + auth="team", + surfaces=("team",), + path_params=("webhookId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="WebhookData"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ApiErrorBody",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Get webhook deliveries + "GET /webhooks/{webhookId}/deliveries": Operation( + operation_id="listWebhookDeliveries", + method="GET", + path="/webhooks/{webhookId}/deliveries", + auth="team", + surfaces=("team",), + path_params=("webhookId",), + query="ListWebhookDeliveriesQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ListWebhookDeliveriesResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)}, + pagination=Pagination(style="cursor", items="WebhookDeliveryListData", cursor_param="cursor", size_param=None), + deprecated=False, + ), + # Get a specific webhook delivery + "GET /webhooks/{webhookId}/deliveries/{deliveryId}": Operation( + operation_id="getWebhookDelivery", + method="GET", + path="/webhooks/{webhookId}/deliveries/{deliveryId}", + auth="team", + surfaces=("team",), + path_params=("webhookId", "deliveryId"), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="WebhookDeliveryData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ApiErrorBody", "ErrorMessage"), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Reschedule a message + "PATCH /messages/{messageId}": Operation( + operation_id="rescheduleMessage", + method="PATCH", + path="/messages/{messageId}", + auth="either", + surfaces=("team",), + path_params=("messageId",), + query=None, + headers=(), + request=RequestBody(type="RescheduleMessageRequest", content_type="application/json", required=True), + response=ResponseBody(status=(200,), content_type="application/json", type="ScheduledMessage"), + errors={401: ("ApiErrorBody", "ErrorMessage"), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("ApiErrorBody", "ErrorMessage"), 422: ("ApiErrorBody", "ValidationErrorBody", "ErrorMessage")}, + pagination=None, + deprecated=False, + ), + # Query email analytics + "POST /analytics": Operation( + operation_id="queryAnalytics", + method="POST", + path="/analytics", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + 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")}, + pagination=None, + deprecated=False, + ), + # Create a new domain + "POST /domains": Operation( + operation_id="createDomain", + method="POST", + path="/domains", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + headers=(), + request=RequestBody(type="StoreDomainData", content_type="application/json", required=True), + response=ResponseBody(status=(201,), content_type="application/json", type="DomainData"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 422: ("ValidationException",), 429: ("ErrorMessage", "ApiErrorBody"), 500: ("ApiErrorBody",)}, + pagination=None, + deprecated=False, + ), + # Verify all DNS records for a domain + "POST /domains/{domainId}/dns-records/verify": Operation( + operation_id="verifyDomainDnsRecords", + method="POST", + path="/domains/{domainId}/dns-records/verify", + auth="team", + surfaces=("team",), + path_params=("domainId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="DnsVerificationSuccessResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 422: ("DnsVerificationFailureResponse",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Verify a specific DNS record + "POST /domains/{domainId}/dns-records/{recordId}/verify": Operation( + operation_id="verifyDomainDnsRecord", + method="POST", + path="/domains/{domainId}/dns-records/{recordId}/verify", + auth="team", + surfaces=("team",), + path_params=("domainId", "recordId"), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="MessageResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ApiErrorBody", "ErrorMessage"), 422: ("DnsRecordVerificationFailureResponse",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Cancel a scheduled message + "POST /messages/{messageId}/cancel": Operation( + operation_id="cancelScheduledMessage", + method="POST", + path="/messages/{messageId}/cancel", + auth="either", + surfaces=("team",), + path_params=("messageId",), + query=None, + 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",)}, + pagination=None, + deprecated=False, + ), + # Process one quarantined inbound message + "POST /messages/{messageId}/process": Operation( + operation_id="processInboundMessage", + method="POST", + path="/messages/{messageId}/process", + auth="team", + surfaces=("team",), + path_params=("messageId",), + query=None, + headers=("Idempotency-Key",), + request=None, + response=ResponseBody(status=(202,), content_type="application/json", type="ProcessInboundMessageResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("ProcessInboundMessageConflict", "ErrorMessage"), 422: ("ErrorMessage",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Create a new project + "POST /projects": Operation( + operation_id="createProject", + method="POST", + path="/projects", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + headers=(), + request=RequestBody(type="StoreProjectData", content_type="application/json", required=True), + response=ResponseBody(status=(201,), content_type="application/json", type="ProjectCreatedData"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 422: ("ValidationException",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Resend report forwarding verification code + "POST /projects/{projectId}/report-forwarding/resend-code": Operation( + operation_id="resendReportForwardingCode", + method="POST", + path="/projects/{projectId}/report-forwarding/resend-code", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ResendReportForwardingCodeResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ModelNotFoundException",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Verify report forwarding destination + "POST /projects/{projectId}/report-forwarding/verify": Operation( + operation_id="verifyReportForwarding", + method="POST", + path="/projects/{projectId}/report-forwarding/verify", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query=None, + headers=(), + request=RequestBody(type="VerifyReportForwardingRequest", content_type="application/json", required=True), + response=ResponseBody(status=(200,), content_type="application/json", type="VerifyReportForwardingResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ModelNotFoundException",), 422: ("ValidationException",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Rotate Project API token (legacy) + # Deprecated. + "POST /projects/{projectId}/rotate-token": Operation( + operation_id="rotateProjectToken", + method="POST", + path="/projects/{projectId}/rotate-token", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="RotateProjectTokenResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=True, + ), + # Create a new route + "POST /projects/{projectId}/routes": Operation( + operation_id="createRoute", + method="POST", + path="/projects/{projectId}/routes", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query=None, + headers=(), + request=RequestBody(type="StoreRouteData", content_type="application/json", required=True), + response=ResponseBody(status=(201,), content_type="application/json", type="RouteMutationResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 422: ("ApiErrorBody", "ValidationErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Verify inbound domain for a route + "POST /routes/{routeId}/verify-inbound-domain": Operation( + operation_id="verifyRouteInboundDomain", + method="POST", + path="/routes/{routeId}/verify-inbound-domain", + auth="team", + surfaces=("team",), + path_params=("routeId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="InboundDomainVerificationResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 422: ("InboundDomainVerificationResponse", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Send an email + "POST /send": Operation( + operation_id="sendMail", + method="POST", + path="/send", + auth="sending", + surfaces=("sending",), + path_params=(), + query=None, + headers=("Idempotency-Key",), + request=RequestBody(type="SendMailRequest", content_type="application/json", required=True), + response=ResponseBody(status=(202,), content_type="application/json", type="SendMailResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 409: ("ErrorMessage",), 422: ("ErrorMessage", "ApiErrorBody", "ValidationErrorBody"), 429: ("ApiErrorBody",), 500: ("ApiErrorBody",)}, + pagination=None, + deprecated=False, + ), + # Send multiple emails in a batch + "POST /send/batch": Operation( + operation_id="sendBatchMail", + method="POST", + path="/send/batch", + auth="sending", + surfaces=("sending",), + path_params=(), + query=None, + headers=("Idempotency-Key",), + request=RequestBody(type="SendBatchMailRequest", content_type="application/json", required=True), + response=ResponseBody(status=(202,), content_type="application/json", type="SendBatchMailResponse"), + errors={401: ("ErrorMessage",), 403: ("ApiErrorBody",), 409: ("ErrorMessage",), 422: ("ApiErrorBody", "ValidationErrorBody", "ErrorMessage"), 429: ("ApiErrorBody",), 500: ("ApiErrorBody",)}, + pagination=None, + deprecated=False, + ), + # Add email(s) to suppression list + "POST /suppressions": Operation( + operation_id="createSuppressions", + method="POST", + path="/suppressions", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + headers=(), + request=RequestBody(type="StoreSuppressionData", content_type="application/json", required=True), + response=ResponseBody(status=(201,), content_type="application/json", type="SuppressionStoreResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ModelNotFoundException",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Create a new webhook + "POST /webhooks": Operation( + operation_id="createWebhook", + method="POST", + path="/webhooks", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + headers=(), + request=RequestBody(type="StoreWebhookData", content_type="application/json", required=True), + response=ResponseBody(status=(201,), content_type="application/json", type="WebhookSecretResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 422: ("ValidationException",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Regenerate webhook secret + "POST /webhooks/{webhookId}/regenerate-secret": Operation( + operation_id="regenerateWebhookSecret", + method="POST", + path="/webhooks/{webhookId}/regenerate-secret", + auth="team", + surfaces=("team",), + path_params=("webhookId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="WebhookSecretResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Test a webhook by sending a sample payload + "POST /webhooks/{webhookId}/test": Operation( + operation_id="testWebhook", + method="POST", + path="/webhooks/{webhookId}/test", + auth="team", + surfaces=("team",), + path_params=("webhookId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="TestWebhookResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Update projects associated with a domain + "PUT /domains/{domainId}/projects": Operation( + operation_id="updateDomainProjects", + method="PUT", + path="/domains/{domainId}/projects", + auth="team", + surfaces=("team",), + path_params=("domainId",), + query=None, + headers=(), + request=RequestBody(type="UpdateDomainProjectsData", content_type="application/json", required=True), + response=ResponseBody(status=(200,), content_type="application/json", type="DomainMutationResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 422: ("ApiErrorBody", "ValidationErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Update project settings + "PUT /projects/{projectId}": Operation( + operation_id="updateProject", + method="PUT", + path="/projects/{projectId}", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query=None, + headers=(), + request=RequestBody(type="UpdateProjectData", content_type="application/json", required=False), + response=ResponseBody(status=(200,), content_type="application/json", type="ProjectMutationResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Update report forwarding destination + "PUT /projects/{projectId}/report-forwarding": Operation( + operation_id="updateReportForwarding", + method="PUT", + path="/projects/{projectId}/report-forwarding", + auth="team", + surfaces=("team",), + path_params=("projectId",), + query=None, + headers=(), + request=RequestBody(type="ReportForwardingRequest", content_type="application/json", required=True), + response=ResponseBody(status=(200,), content_type="application/json", type="UpdateReportForwardingResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ModelNotFoundException",), 422: ("ValidationException",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Update route settings + "PUT /routes/{routeId}": Operation( + operation_id="updateRoute", + method="PUT", + path="/routes/{routeId}", + auth="team", + surfaces=("team",), + path_params=("routeId",), + query=None, + headers=(), + request=RequestBody(type="UpdateRouteData", content_type="application/json", required=False), + response=ResponseBody(status=(200,), content_type="application/json", type="RouteMutationResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 422: ("ValidationException",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + # Update team settings + "PUT /team": Operation( + operation_id="updateTeam", + method="PUT", + path="/team", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + headers=(), + request=RequestBody(type="UpdateTeamData", content_type="application/json", required=False), + response=ResponseBody(status=(200,), content_type="application/json", type="TeamMutationResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Replace a team member's assignment + "PUT /team/members/{userId}/assignment": Operation( + operation_id="updateTeamMemberAssignment", + method="PUT", + path="/team/members/{userId}/assignment", + auth="team", + surfaces=("team",), + path_params=("userId",), + query=None, + headers=(), + request=RequestBody(type="UpdateTeamMemberAssignmentData", content_type="application/json", required=True), + response=ResponseBody(status=(200,), content_type="application/json", type="TeamMemberData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("RbacConflictException",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Update webhook settings + "PUT /webhooks/{webhookId}": Operation( + operation_id="updateWebhook", + method="PUT", + path="/webhooks/{webhookId}", + auth="team", + surfaces=("team",), + path_params=("webhookId",), + query=None, + headers=(), + request=RequestBody(type="UpdateWebhookData", content_type="application/json", required=False), + response=ResponseBody(status=(200,), content_type="application/json", type="WebhookMutationResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 422: ("ValidationException",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), + } +) diff --git a/src/lettermint/_generated/types.py b/src/lettermint/_generated/types.py new file mode 100644 index 0000000..86d442e --- /dev/null +++ b/src/lettermint/_generated/types.py @@ -0,0 +1,3029 @@ +# Generated by lettermint/sdk-generator — do not edit. +# Spec: lettermint/lettermint@80d8ab2a2d73644c2bdee8a4bb9d3565aa017d72 (feat/openapi-named-schemas) +# sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f +# team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd +# Naming profile: next +"""Request and response types of the Lettermint API. + +TypedDicts mirror the JSON bodies: optional keys are ``NotRequired``, nullable +values include ``None``. Enums are open (``Literal[...] | str``), so values the API +adds later still type-check. +""" + +from __future__ import annotations + +from typing import Any, Generic, Literal, TypeAlias, TypeVar + +from typing_extensions import NotRequired, Required, TypedDict + +__all__ = [ + "AnalyticsBreakdownRow", + "AnalyticsCatalogueDimension", + "AnalyticsCatalogueGroupDimension", + "AnalyticsComparison", + "AnalyticsComparisonValues", + "AnalyticsDimension", + "AnalyticsFilter", + "AnalyticsFilterOperator", + "AnalyticsGroupDimension", + "AnalyticsInterval", + "AnalyticsMeta", + "AnalyticsMetaAlignment", + "AnalyticsMetaCollectionCompleteness", + "AnalyticsMetaComparison", + "AnalyticsMetaTimeBasis", + "AnalyticsMetric", + "AnalyticsMetricChange", + "AnalyticsMetricValues", + "AnalyticsPagination", + "AnalyticsQuery", + "AnalyticsRateBase", + "AnalyticsRateBases", + "AnalyticsResponse", + "AnalyticsResults", + "AnalyticsSection", + "AnalyticsSort", + "AnalyticsSortDirection", + "AnalyticsSummary", + "AnalyticsTimeSeriesPoint", + "ApiErrorBody", + "ApiErrorDetail", + "AttachmentDelivery", + "BlockedFileTypes", + "BuiltInTeamRole", + "CursorPage", + "DeleteSuppressionResponse", + "DeliveryMode", + "DkimMode", + "DmarcPolicy", + "DnsRecordPurpose", + "DnsRecordStatus", + "DnsRecordVerificationFailureResponse", + "DnsVerificationError", + "DnsVerificationFailedRecord", + "DnsVerificationFailureResponse", + "DnsVerificationResultData", + "DnsVerificationResultDataDmarcSpfAlignmentIssue", + "DnsVerificationScope", + "DnsVerificationSuccessResponse", + "DomainData", + "DomainDataProjectsItem", + "DomainDnsRecordData", + "DomainListData", + "DomainMutationResponse", + "DomainStatus", + "ErrorMessage", + "GetDomainQuery", + "GetDomainQueryIncludeItem", + "GetProjectQuery", + "GetProjectQueryIncludeItem", + "GetReportForwardingResponse", + "GetRouteQuery", + "GetRouteQueryIncludeItem", + "GetStatsQuery", + "GetTeamQuery", + "GetTeamQueryIncludeItem", + "InboundDomainVerification", + "InboundDomainVerificationResponse", + "InitialRoutes", + "ListDomainsQuery", + "ListDomainsQueryFilter", + "ListDomainsQueryPage", + "ListDomainsQuerySortItem", + "ListDomainsResponse", + "ListMessageEventsQuery", + "ListMessageEventsQueryPage", + "ListMessageEventsQuerySortItem", + "ListMessageEventsResponse", + "ListMessagesQuery", + "ListMessagesQueryFilter", + "ListMessagesQueryFilterTagsItem", + "ListMessagesQueryPage", + "ListMessagesQuerySortItem", + "ListMessagesResponse", + "ListProjectsQuery", + "ListProjectsQueryFilter", + "ListProjectsQueryPage", + "ListProjectsQuerySortItem", + "ListProjectsResponse", + "ListRoutesQuery", + "ListRoutesQueryFilter", + "ListRoutesQueryPage", + "ListRoutesQuerySortItem", + "ListRoutesResponse", + "ListSuppressionsQuery", + "ListSuppressionsQueryFilter", + "ListSuppressionsQueryPage", + "ListSuppressionsQuerySortItem", + "ListSuppressionsResponse", + "ListTeamMembersQuery", + "ListTeamMembersQueryPage", + "ListTeamMembersResponse", + "ListWebhookDeliveriesQuery", + "ListWebhookDeliveriesQueryFilter", + "ListWebhookDeliveriesQuerySortItem", + "ListWebhookDeliveriesResponse", + "ListWebhooksQuery", + "ListWebhooksQueryFilter", + "ListWebhooksQueryPage", + "ListWebhooksQuerySortItem", + "ListWebhooksResponse", + "MessageAttachmentData", + "MessageAttachmentInput", + "MessageData", + "MessageEventData", + "MessageEventType", + "MessageListData", + "MessageRecipientData", + "MessageResponse", + "MessageStatsData", + "MessageStatus", + "MessageTag", + "MessageTagInput", + "MessageType", + "ModelNotFoundException", + "PendingSendMailResponse", + "Plan", + "ProcessInboundMessageConflict", + "ProcessInboundMessageConflictError", + "ProcessInboundMessageConflictErrorCode", + "ProcessInboundMessageResponse", + "ProcessInboundMessageResult", + "ProjectAccessScope", + "ProjectCreatedData", + "ProjectData", + "ProjectListData", + "ProjectMutationResponse", + "RbacConflictCode", + "RbacConflictException", + "RbacConflictExceptionError", + "RbacPermission", + "RecordType", + "ReportForwardingRequest", + "ReportForwardingResource", + "RescheduleMessageRequest", + "ResendReportForwardingCodeResponse", + "RotateProjectTokenResponse", + "RouteData", + "RouteDataSettings", + "RouteListData", + "RouteMutationResponse", + "RouteStatisticData", + "RouteType", + "SandboxResult", + "ScheduledMessage", + "ScheduledSendMailResponse", + "SendBatchMailRequest", + "SendBatchMailResponse", + "SendMailRequest", + "SendMailRequestSettings", + "SendMailResponse", + "SpamSymbol", + "StatsDailyData", + "StatsData", + "StatsInboundData", + "StatsTotalsData", + "StatsTypeData", + "StoreDomainData", + "StoreProjectData", + "StoreRouteData", + "StoreSuppressionData", + "StoreWebhookData", + "SuppressedRecipientData", + "SuppressionAppliesTo", + "SuppressionCreateReason", + "SuppressionCreateScope", + "SuppressionDeletedResponse", + "SuppressionReason", + "SuppressionReviewResponse", + "SuppressionReviewResponseStatus", + "SuppressionScope", + "SuppressionSourceMessageData", + "SuppressionStoreResponse", + "SuppressionStoreResult", + "SuppressionType", + "TeamAddonData", + "TeamData", + "TeamMemberData", + "TeamMemberDataRole", + "TeamMemberProjectAccessData", + "TeamMemberProjectAccessDataProjectsItem", + "TeamMutationResponse", + "TeamRoleData", + "TeamRoleListResponse", + "TeamType", + "TeamUsageDetailData", + "TeamUsagePeriodData", + "TestWebhookResponse", + "TlsPolicy", + "TooManyRequests", + "UpdateDomainProjectsData", + "UpdateProjectData", + "UpdateReportForwardingResponse", + "UpdateRouteData", + "UpdateRouteInboundSettingsData", + "UpdateRouteSettingsData", + "UpdateTeamData", + "UpdateTeamMemberAssignmentData", + "UpdateTeamMemberAssignmentDataProjectAccess", + "UpdateWebhookData", + "ValidationErrorBody", + "ValidationException", + "VerifyReportForwardingRequest", + "VerifyReportForwardingResponse", + "WebhookBasicAuthData", + "WebhookData", + "WebhookDeliveryData", + "WebhookDeliveryListData", + "WebhookDeliveryModeFilter", + "WebhookDeliveryStatus", + "WebhookEvent", + "WebhookListData", + "WebhookMutationResponse", + "WebhookScope", + "WebhookSecretData", + "WebhookSecretResponse", +] + +T = TypeVar("T") + + +# Metric values for one group of dimension values. +AnalyticsBreakdownRow = TypedDict( + "AnalyticsBreakdownRow", + { + "metrics": Required["AnalyticsMetricValues"], + "rate_bases": Required["AnalyticsRateBases"], + "previous": NotRequired["AnalyticsComparisonValues"], + "change": NotRequired["dict[str, AnalyticsMetricChange]"], + "dimensions": Required[dict[str, str | None]], + "trend": NotRequired["list[AnalyticsTimeSeriesPoint]"], + }, +) + + +AnalyticsCatalogueDimension: TypeAlias = ( + Literal[ + "project_id", + "route_id", + "message_type", + "sending_domain", + "recipient_domain", + "provider_family", + "provider_subtype", + "from_address", + "subject", + "dedicated_ip", + "dedicated_pool_id", + "infrastructure_state", + "smtp_code", + "enhanced_status_code", + "enhanced_status_category", + "bounce_classification", + "failure_reason", + "smtp_response_group", + "engagement_state", + "request_actor", + "proxy_type", + "is_human", + "is_privacy", + "is_bot", + "is_scanner", + ] + | str +) + + +AnalyticsCatalogueGroupDimension: TypeAlias = ( + Literal[ + "project_id", + "route_id", + "message_type", + "sending_domain", + "recipient_domain", + "provider_family", + "provider_subtype", + "from_address", + "subject", + "dedicated_ip", + "dedicated_pool_id", + "infrastructure_state", + "smtp_code", + "enhanced_status_code", + "enhanced_status_category", + "bounce_classification", + "failure_reason", + "smtp_response_group", + "engagement_state", + "request_actor", + "proxy_type", + ] + | str +) + + +# A comparison period. +AnalyticsComparison: TypeAlias = Literal["previous_period"] | str + + +# Metric values for the comparison period. +AnalyticsComparisonValues = TypedDict( + "AnalyticsComparisonValues", + { + "metrics": Required["AnalyticsMetricValues"], + "rate_bases": Required["AnalyticsRateBases"], + }, +) + + +# A catalogue dimension or tag: to filter by. is_human, is_privacy, is_bot and is_scanner select an engagement class: filter them with the value 1 to restrict engagement metrics to that class. +AnalyticsDimension: TypeAlias = AnalyticsCatalogueDimension | str + + +# A dimension filter. +AnalyticsFilter = TypedDict( + "AnalyticsFilter", + { + "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. + "values": NotRequired[list[str]], + }, +) + + +# A filter operator. +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. +AnalyticsGroupDimension: TypeAlias = AnalyticsCatalogueGroupDimension | str + + +# A time-series bucket size. +AnalyticsInterval: TypeAlias = Literal["hour", "day"] | str + + +# The time window and data availability of the results. +AnalyticsMeta = TypedDict( + "AnalyticsMeta", + { + "time_basis": Required["AnalyticsMetaTimeBasis"], + "timezone": Required[str], + "interval": Required["AnalyticsInterval"], + # Start of the window read, aligned down to the alignment grain. + "from": Required[str], + # Exclusive end of the window, aligned up to the alignment grain. + "to": Required[str], + # Exclusive end of the counted data: to, or generated_at while the window is ongoing. + "effective_to": Required[str], + # Grain the requested boundaries were widened to: whole UTC hours, or whole UTC days for personal-data dimensions. Counts cover exactly from to effective_to. + "alignment": Required["AnalyticsMetaAlignment"], + "generated_at": Required[str], + "available_since": Required[str], + "partial": Required[bool], + "ongoing": Required[bool], + "collection_completeness": Required["AnalyticsMetaCollectionCompleteness"], + # Latest ingestion among matching rollup rows. This is not a completeness watermark. + "last_ingested_at": Required[str | None], + "metric_definition_version": Required[str], + "ranked_group_limit": Required[int], + "comparison": NotRequired["AnalyticsMetaComparison"], + }, +) + + +# Grain the requested boundaries were widened to: whole UTC hours, or whole UTC days for personal-data dimensions. Counts cover exactly from to effective_to. +AnalyticsMetaAlignment: TypeAlias = Literal["hour", "day"] | str + + +AnalyticsMetaCollectionCompleteness: TypeAlias = Literal["best_effort"] | str + + +AnalyticsMetaComparison = TypedDict( + "AnalyticsMetaComparison", + { + "from": Required[str], + "to": Required[str], + "partial": Required[bool], + }, +) + + +AnalyticsMetaTimeBasis: TypeAlias = Literal["event"] | str + + +# A metric from the analytics catalogue. +AnalyticsMetric: TypeAlias = ( + Literal[ + "accepted", + "processed", + "suppressed", + "policy_rejected", + "application_failed", + "mta_accepted", + "canceled", + "messages", + "delivered", + "bounced", + "soft_bounced", + "administratively_bounced", + "deferred_recipients", + "deferred_events", + "delivery_attempts", + "attempted_recipients", + "transport_outcome_recipients", + "effective_delivered", + "open_tracked_delivered", + "click_tracked_delivered", + "out_of_band_bounced_recipients", + "out_of_band_bounce_events", + "complained", + "unsubscribed", + "human_opens", + "human_opens_events", + "human_clicks", + "human_clicks_events", + "machine_opens", + "machine_opens_events", + "machine_clicks", + "machine_clicks_events", + "privacy_opens", + "privacy_opens_events", + "privacy_clicks", + "privacy_clicks_events", + "bot_opens", + "bot_opens_events", + "bot_clicks", + "bot_clicks_events", + "scanner_opens", + "scanner_opens_events", + "scanner_clicks", + "scanner_clicks_events", + "observed_opens", + "observed_opens_events", + "observed_clicks", + "observed_clicks_events", + "delivery_rate", + "effective_delivery_rate", + "bounce_rate", + "deferral_rate", + "complaint_rate", + "human_open_rate", + "human_click_rate", + "processing_latency_p50_ms", + "processing_latency_p95_ms", + "processing_latency_p99_ms", + "processing_latency_samples", + "delivery_latency_p50_ms", + "delivery_latency_p95_ms", + "delivery_latency_p99_ms", + "delivery_latency_samples", + "total_latency_p50_ms", + "total_latency_p95_ms", + "total_latency_p99_ms", + "total_latency_samples", + ] + | str +) + + +# Change of a metric against the comparison period. +AnalyticsMetricChange = TypedDict( + "AnalyticsMetricChange", + { + "absolute": NotRequired[float | None], + "relative": NotRequired[float | None], + "percentage_points": NotRequired[float | None], + }, +) + + +# Values of the selected metrics. Rates are fractions; null when not available. +AnalyticsMetricValues = TypedDict( + "AnalyticsMetricValues", + { + "accepted": NotRequired[int | None], + "processed": NotRequired[int | None], + "suppressed": NotRequired[int | None], + "policy_rejected": NotRequired[int | None], + "application_failed": NotRequired[int | None], + "mta_accepted": NotRequired[int | None], + "canceled": NotRequired[int | None], + "messages": NotRequired[int | None], + "delivered": NotRequired[int | None], + "bounced": NotRequired[int | None], + "soft_bounced": NotRequired[int | None], + "administratively_bounced": NotRequired[int | None], + "deferred_recipients": NotRequired[int | None], + "deferred_events": NotRequired[int | None], + "delivery_attempts": NotRequired[int | None], + "attempted_recipients": NotRequired[int | None], + "transport_outcome_recipients": NotRequired[int | None], + "effective_delivered": NotRequired[int | None], + "open_tracked_delivered": NotRequired[int | None], + "click_tracked_delivered": NotRequired[int | None], + "out_of_band_bounced_recipients": NotRequired[int | None], + "out_of_band_bounce_events": NotRequired[int | None], + "complained": NotRequired[int | None], + "unsubscribed": NotRequired[int | None], + "human_opens": NotRequired[int | None], + "human_opens_events": NotRequired[int | None], + "human_clicks": NotRequired[int | None], + "human_clicks_events": NotRequired[int | None], + "machine_opens": NotRequired[int | None], + "machine_opens_events": NotRequired[int | None], + "machine_clicks": NotRequired[int | None], + "machine_clicks_events": NotRequired[int | None], + "privacy_opens": NotRequired[int | None], + "privacy_opens_events": NotRequired[int | None], + "privacy_clicks": NotRequired[int | None], + "privacy_clicks_events": NotRequired[int | None], + "bot_opens": NotRequired[int | None], + "bot_opens_events": NotRequired[int | None], + "bot_clicks": NotRequired[int | None], + "bot_clicks_events": NotRequired[int | None], + "scanner_opens": NotRequired[int | None], + "scanner_opens_events": NotRequired[int | None], + "scanner_clicks": NotRequired[int | None], + "scanner_clicks_events": NotRequired[int | None], + "observed_opens": NotRequired[int | None], + "observed_opens_events": NotRequired[int | None], + "observed_clicks": NotRequired[int | None], + "observed_clicks_events": NotRequired[int | None], + "delivery_rate": NotRequired[float | None], + "effective_delivery_rate": NotRequired[float | None], + "bounce_rate": NotRequired[float | None], + "deferral_rate": NotRequired[float | None], + "complaint_rate": NotRequired[float | None], + "human_open_rate": NotRequired[float | None], + "human_click_rate": NotRequired[float | None], + "processing_latency_p50_ms": NotRequired[float | None], + "processing_latency_p95_ms": NotRequired[float | None], + "processing_latency_p99_ms": NotRequired[float | None], + "processing_latency_samples": NotRequired[int | None], + "delivery_latency_p50_ms": NotRequired[float | None], + "delivery_latency_p95_ms": NotRequired[float | None], + "delivery_latency_p99_ms": NotRequired[float | None], + "delivery_latency_samples": NotRequired[int | None], + "total_latency_p50_ms": NotRequired[float | None], + "total_latency_p95_ms": NotRequired[float | None], + "total_latency_p99_ms": NotRequired[float | None], + "total_latency_samples": NotRequired[int | None], + }, +) + + +# Breakdown pagination. +AnalyticsPagination = TypedDict( + "AnalyticsPagination", + { + "total_groups": Required[int], + "returned_groups": Required[int], + "next_cursor": Required[str | None], + "truncated": Required[bool], + }, +) + + +# An analytics query. +AnalyticsQuery = TypedDict( + "AnalyticsQuery", + { + # Every count is attributed to the time its event occurred, so a message accepted in one bucket can be delivered in the next. Delivery outcomes happen once per recipient, so their event counts equal recipient counts. messages counts accepted messages; mta_accepted and attempted_recipients count recipients the MTA received. Under provider, infrastructure and diagnosis dimensions, sent/attempted count delivery attempts: mta_accepted and attempted_recipients equal delivery_attempts when a query groups or filters by provider_family, provider_subtype, dedicated_ip, dedicated_pool_id, infrastructure_state or an SMTP diagnosis dimension, because the MTA assigns those values at the delivery attempt; the other lifecycle metrics return null there, and a breakdown by those dimensions omits the unknown group. deferred_recipients counts recipients delayed on the first attempt; deferred_events counts every deferral. delivery_attempts counts delivered, deferred and in-band bounced attempts. transport_outcome_recipients = delivered + bounced + soft_bounced. effective_delivered = delivered minus out-of-band bounces in the same period and can be negative for a single bucket or group. Engagement counts per class are unique per recipient, counted when the first qualifying open or click occurred; _events variants count every observation. Rates are fractions; zero denominators return null, and event-time rates can exceed 1. Latency percentiles are milliseconds read from log-scale histograms (error below 15 percent); _samples returns the sample count. Personal message dimensions support daily buckets over the message retention period. delivery_rate = delivered / transport_outcome_recipients. effective_delivery_rate = effective_delivered / transport_outcome_recipients. bounce_rate = bounced / transport_outcome_recipients. deferral_rate = deferred_events / delivery_attempts. complaint_rate = complained / delivered. human_open_rate = human_opens / open_tracked_delivered. human_click_rate = human_clicks / click_tracked_delivered. + "metrics": Required["list[AnalyticsMetric]"], + # Inclusive calendar date or RFC 3339 instant. Default: 30 calendar days including today. Instants are aligned outward to whole UTC hours, or whole UTC days when from_address or subject is grouped or filtered; meta.from, meta.to, meta.effective_to and meta.alignment report the exact window used. + "from": NotRequired[str], + # Inclusive calendar date or exclusive RFC 3339 instant. Maximum window: 90 days. Instants are aligned outward to whole UTC hours, or whole UTC days when from_address or subject is grouped or filtered; meta.from, meta.to, meta.effective_to and meta.alignment report the exact window used. + "to": NotRequired[str], + # IANA time zone. Default: UTC. + "timezone": NotRequired[str], + # Result sections. Default: summary only. breakdown requires group_by, and group_by requires breakdown. + "include": NotRequired["list[AnalyticsSection]"], + # Up to three grouping dimensions for breakdown rows. Requires breakdown in include. Default: no grouping. + "group_by": NotRequired["list[AnalyticsGroupDimension]"], + # Dimension filters combined with AND. Default: no filters. Each metric must support every filter dimension. + "filters": NotRequired["list[AnalyticsFilter]"], + # Time-series bucket size. Default: day. + "interval": NotRequired["AnalyticsInterval"], + # Compares with the preceding period of the same duration. Omit for no comparison. + "compare": NotRequired["AnalyticsComparison"], + # Includes trends for breakdown rows. Requires breakdown and group_by. Default: false. + "include_trend": NotRequired[bool], + # Optional breakdown sort. Omit to use the default row order. + "sort": NotRequired["AnalyticsSort"], + # Maximum breakdown rows per page. Default: 50; maximum: 200. + "limit": NotRequired[int], + # Repeat the same query with this cursor. It expires within 60 seconds. + "cursor": NotRequired[str], + }, +) + + +# Numerator and denominator of a rate metric. +AnalyticsRateBase = TypedDict( + "AnalyticsRateBase", + { + "numerator": Required[int | None], + "denominator": Required[int | None], + }, +) + + +# Numerators and denominators of the selected rate metrics. +AnalyticsRateBases = TypedDict( + "AnalyticsRateBases", + { + "delivery_rate": NotRequired["AnalyticsRateBase"], + "effective_delivery_rate": NotRequired["AnalyticsRateBase"], + "bounce_rate": NotRequired["AnalyticsRateBase"], + "deferral_rate": NotRequired["AnalyticsRateBase"], + "complaint_rate": NotRequired["AnalyticsRateBase"], + "human_open_rate": NotRequired["AnalyticsRateBase"], + "human_click_rate": NotRequired["AnalyticsRateBase"], + }, +) + + +# Analytics results with their window metadata and pagination. +AnalyticsResponse = TypedDict( + "AnalyticsResponse", + { + "data": Required["AnalyticsResults"], + "meta": Required["AnalyticsMeta"], + "pagination": Required["AnalyticsPagination"], + }, +) + + +# The selected result sections. +AnalyticsResults = TypedDict( + "AnalyticsResults", + { + "summary": NotRequired["AnalyticsSummary"], + "time_series": NotRequired["list[AnalyticsTimeSeriesPoint]"], + "breakdown": NotRequired["list[AnalyticsBreakdownRow]"], + }, +) + + +# A result section. +AnalyticsSection: TypeAlias = Literal["summary", "time_series", "breakdown"] | str + + +# A breakdown sort order. +AnalyticsSort = TypedDict( + "AnalyticsSort", + { + # Metric used to sort breakdown rows. Must also be selected in metrics. + "metric": Required["AnalyticsMetric"], + # Ascending or descending order for the selected sort metric. + "direction": Required["AnalyticsSortDirection"], + }, +) + + +# Ascending or descending order for the selected sort metric. +AnalyticsSortDirection: TypeAlias = Literal["asc", "desc"] | str + + +# Metric values for the whole window. +AnalyticsSummary = TypedDict( + "AnalyticsSummary", + { + "metrics": Required["AnalyticsMetricValues"], + "rate_bases": Required["AnalyticsRateBases"], + "previous": NotRequired["AnalyticsComparisonValues"], + "change": NotRequired["dict[str, AnalyticsMetricChange]"], + }, +) + + +# Metric values for one time-series bucket. +AnalyticsTimeSeriesPoint = TypedDict( + "AnalyticsTimeSeriesPoint", + { + "metrics": Required["AnalyticsMetricValues"], + "rate_bases": Required["AnalyticsRateBases"], + "previous": NotRequired["AnalyticsComparisonValues"], + "change": NotRequired["dict[str, AnalyticsMetricChange]"], + "from": Required[str], + "to": Required[str], + "available": Required[bool], + "partial": Required[bool], + }, +) + + +# Structured API error. +ApiErrorBody = TypedDict( + "ApiErrorBody", + { + "error": Required["ApiErrorDetail"], + }, +) + + +ApiErrorDetail = TypedDict( + "ApiErrorDetail", + { + # Machine-readable error code, for example `RESOURCE_NOT_FOUND`. + "code": Required[str], + # Human-readable error message. + "message": Required[str], + # Additional context. Its shape depends on the error code. + "details": NotRequired[dict[str, Any] | list[Any] | None], + }, +) + + +AttachmentDelivery: TypeAlias = Literal["inline", "url"] | str + + +# File types that cannot be sent as email attachments. +BlockedFileTypes = TypedDict( + "BlockedFileTypes", + { + # Blocked file extensions, without the leading dot + "extensions": Required[list[str]], + # Blocked MIME types + "mime_types": Required[list[str]], + }, +) + + +BuiltInTeamRole: TypeAlias = Literal["owner", "admin", "member"] | str + + +class CursorPage(TypedDict, Generic[T]): + """A page of a cursor-paginated list. + + Pass ``next_cursor`` back as the cursor query parameter to read the next + page; it is ``None`` on the last page. + """ + + data: list[T] + # Base path for paginator generated URLs. + path: str | None + # Number of items shown per page. + per_page: int + # The "cursor" that points to the next set of items. + next_cursor: str | None + next_page_url: str | None + # The "cursor" that points to the previous set of items. + prev_cursor: str | None + prev_page_url: str | None + + +# Confirmation that a suppression was removed. +SuppressionDeletedResponse = TypedDict( + "SuppressionDeletedResponse", + { + "success": Required[Literal[True]], + "status": Required[Literal['removed']], + # Human-readable confirmation + "message": Required[str], + # Confidence of the automated removal assessment, when one ran + "confidence": NotRequired[float], + }, +) + + +# A suppression that stays active while a support ticket reviews its removal. +SuppressionReviewResponse = TypedDict( + "SuppressionReviewResponse", + { + "success": Required[Literal[True]], + "status": Required["SuppressionReviewResponseStatus"], + # Human-readable confirmation + "message": Required[str], + # The identifier of the support ticket that reviews the removal + "ticket_identifier": Required[str], + # Confidence of the automated removal assessment, when one ran + "confidence": NotRequired[float], + }, +) + + +DeleteSuppressionResponse: TypeAlias = SuppressionDeletedResponse | SuppressionReviewResponse + + +DeliveryMode: TypeAlias = Literal["live", "sandbox"] | str + + +DkimMode: TypeAlias = Literal["legacy_txt", "managed_cname"] | str + + +DmarcPolicy: TypeAlias = Literal["none", "quarantine", "reject"] | str + + +DnsRecordPurpose: TypeAlias = ( + Literal[ + "return_path", + "dmarc", + "dkim_legacy", + "dkim_primary", + "dkim_secondary", + ] + | str +) + + +# | | +# |---| +# | `active`
The record is active and verified. | +# | `failed`
The record could not be verified. | +# | `pending`
The record is pending verification. | +DnsRecordStatus: TypeAlias = Literal["active", "failed", "pending"] | str + + +# A DNS record that did not pass verification, with the verification details. +DnsRecordVerificationFailureResponse = TypedDict( + "DnsRecordVerificationFailureResponse", + { + # Human-readable verification error + "message": Required[str], + "result": Required["DnsVerificationResultData"], + }, +) + + +# Why a DNS record did not pass verification. +DnsVerificationError = TypedDict( + "DnsVerificationError", + { + # The DNS record ID + "record_id": Required[str], + "error": Required[str], + }, +) + + +# A DNS record that did not pass verification. +DnsVerificationFailedRecord = TypedDict( + "DnsVerificationFailedRecord", + { + # The DNS record ID + "id": Required[str], + "type": Required["RecordType"], + # The record hostname + "name": Required[str], + }, +) + + +# One or more required DNS records of the domain did not pass verification. +DnsVerificationFailureResponse = TypedDict( + "DnsVerificationFailureResponse", + { + # Human-readable verification result + "message": Required[str], + # Required records that did not pass verification + "failed_records": Required["list[DnsVerificationFailedRecord]"], + # Verification errors of every failed record, including recommended records + "errors": Required["list[DnsVerificationError]"], + # The number of required records that failed + "failed_count": Required[int], + # The number of required records + "total_count": Required[int], + }, +) + + +DnsVerificationResultData = TypedDict( + "DnsVerificationResultData", + { + "dmarc_spf_alignment_issue": Required["DnsVerificationResultDataDmarcSpfAlignmentIssue | None"], + "verified": Required[bool], + "error": Required[str | None], + "expected_record": Required[str | None], + "found_record": Required[str | None], + "dmarc_policy_domain": Required[str | None], + "dmarc_effective_policy": Required["DmarcPolicy | None"], + }, +) + + +DnsVerificationResultDataDmarcSpfAlignmentIssue = TypedDict( + "DnsVerificationResultDataDmarcSpfAlignmentIssue", + { + "code": Required[str], + "blocks_verification": Required[bool], + "return_path_domain": Required[str], + }, +) + + +DnsVerificationScope: TypeAlias = ( + Literal[ + "required", + "recommended", + "migration", + "deprecated", + ] + | str +) + + +# All required DNS records of the domain passed verification. +DnsVerificationSuccessResponse = TypedDict( + "DnsVerificationSuccessResponse", + { + # Human-readable verification result + "message": Required[str], + # Recommended records that did not pass verification. They do not block verification + "recommended_failed_records": Required["list[DnsVerificationFailedRecord]"], + }, +) + + +DomainData = TypedDict( + "DomainData", + { + "id": Required[str], + "domain": Required[str], + "dkim_mode": Required["DkimMode"], + "rotation_ready": Required[bool], + "status_changed_at": Required[str | None], + "dns_records": NotRequired["list[DomainDnsRecordData]"], + "projects": NotRequired["list[DomainDataProjectsItem]"], + "created_at": Required[str], + }, +) + + +DomainDataProjectsItem = TypedDict( + "DomainDataProjectsItem", + { + "id": Required[str], + "name": Required[str], + }, +) + + +DomainDnsRecordData = TypedDict( + "DomainDnsRecordData", + { + "id": Required[str], + "type": Required["RecordType"], + "hostname": Required[str], + "fqdn": Required[str], + "content": Required[str], + "status": Required["DnsRecordStatus"], + "purpose": Required["DnsRecordPurpose"], + "verification_scope": Required["DnsVerificationScope"], + "required_for_verification": Required[bool], + "verified_at": Required[str | None], + "last_checked_at": Required[str | None], + }, +) + + +DomainListData = TypedDict( + "DomainListData", + { + "id": Required[str], + "domain": Required[str], + "status": Required["DomainStatus"], + "dkim_mode": Required["DkimMode"], + "status_changed_at": Required[str | None], + "created_at": Required[str], + }, +) + + +# The domain after a change, with a confirmation message. +DomainMutationResponse = TypedDict( + "DomainMutationResponse", + { + "data": Required["DomainData"], + # Human-readable confirmation + "message": Required[str], + }, +) + + +# | | +# |---| +# | `verified`
The domain is active and verified. | +# | `partially_verified`
Some DNS records of the domain could not be verified. | +# | `pending_verification`
The domain is new and verification is pending. | +# | `failed_verification`
The domain verification failed. | +DomainStatus: TypeAlias = ( + Literal[ + "verified", + "partially_verified", + "pending_verification", + "failed_verification", + ] + | str +) + + +# Error with only a message. +ErrorMessage = TypedDict( + "ErrorMessage", + { + # Error overview. + "message": Required[str], + }, +) + + +# Query parameters of `GET /domains/{domainId}` (getDomain). +GetDomainQuery = TypedDict( + "GetDomainQuery", + { + "include": NotRequired["list[GetDomainQueryIncludeItem]"], + }, +) + + +GetDomainQueryIncludeItem: TypeAlias = Literal["dnsRecords", "projects"] | str + + +# Query parameters of `GET /projects/{projectId}` (getProject). +GetProjectQuery = TypedDict( + "GetProjectQuery", + { + "include": NotRequired["list[GetProjectQueryIncludeItem]"], + }, +) + + +GetProjectQueryIncludeItem: TypeAlias = ( + Literal[ + "routes", + "routesCount", + "routesExists", + "domains", + "domainsCount", + "domainsExists", + "messageStats", + "messageStatsCount", + "messageStatsExists", + ] + | str +) + + +GetReportForwardingResponse = TypedDict( + "GetReportForwardingResponse", + { + "data": Required["ReportForwardingResource"], + }, +) + + +# Query parameters of `GET /routes/{routeId}` (getRoute). +GetRouteQuery = TypedDict( + "GetRouteQuery", + { + # Include legacy machine observations in open and click totals. Privacy opens are reported in the observed and privacy fields without this option. + "include_machine": NotRequired[bool], + "include": NotRequired["list[GetRouteQueryIncludeItem]"], + }, +) + + +GetRouteQueryIncludeItem: TypeAlias = ( + Literal[ + "project", + "projectCount", + "projectExists", + "statistics", + ] + | str +) + + +# Query parameters of `GET /stats` (getStats). +GetStatsQuery = TypedDict( + "GetStatsQuery", + { + "from": Required[str], + "to": Required[str], + "project_id": NotRequired[str | None], + "include_machine": NotRequired[bool], + }, +) + + +# Query parameters of `GET /team` (getTeam). +GetTeamQuery = TypedDict( + "GetTeamQuery", + { + "include": NotRequired["list[GetTeamQueryIncludeItem]"], + }, +) + + +GetTeamQueryIncludeItem: TypeAlias = Literal["features", "addons"] | str + + +# Whether the inbound domain MX records point to Lettermint. The MX record lists are only returned when verification fails. +InboundDomainVerification = TypedDict( + "InboundDomainVerification", + { + "verified": Required[bool], + # Human-readable verification result + "message": Required[str], + # The MX records the inbound domain must publish + "expected_mx_records": NotRequired[list[str]], + # The MX records found for the inbound domain + "found_mx_records": NotRequired[list[str]], + }, +) + + +# The result of an inbound domain MX verification. +InboundDomainVerificationResponse = TypedDict( + "InboundDomainVerificationResponse", + { + "data": Required["InboundDomainVerification"], + }, +) + + +InitialRoutes: TypeAlias = Literal["both", "transactional", "broadcast"] | str + + +# Query parameters of `GET /domains` (listDomains). +ListDomainsQuery = TypedDict( + "ListDomainsQuery", + { + "sort": NotRequired["list[ListDomainsQuerySortItem]"], + "page": NotRequired["ListDomainsQueryPage"], + "filter": NotRequired["ListDomainsQueryFilter"], + }, +) + + +# The `filter[...]` query parameters of `GET /domains`. +ListDomainsQueryFilter = TypedDict( + "ListDomainsQueryFilter", + { + # Filter by project ID + "project": NotRequired[str], + "status": NotRequired["DomainStatus"], + # Search by domain name (partial match, case insensitive) + "domain": NotRequired[str], + }, +) + + +# The `page[...]` query parameters of `GET /domains`. +ListDomainsQueryPage = TypedDict( + "ListDomainsQueryPage", + { + # The number of results that will be returned per page. + "size": NotRequired[int], + # The cursor to start the pagination from. + "cursor": NotRequired[str], + }, +) + + +ListDomainsQuerySortItem: TypeAlias = ( + Literal[ + "domain", + "-domain", + "created_at", + "-created_at", + "status_changed_at", + "-status_changed_at", + ] + | str +) + + +ListDomainsResponse: TypeAlias = CursorPage[DomainListData] + + +# Query parameters of `GET /messages/{messageId}/events` (listMessageEvents). +ListMessageEventsQuery = TypedDict( + "ListMessageEventsQuery", + { + # Include security scanner, preview, and other machine-only tracking events. Supported privacy opens are included without this option. + "include_machine_events": NotRequired[bool], + "sort": NotRequired["list[ListMessageEventsQuerySortItem]"], + "page": NotRequired["ListMessageEventsQueryPage"], + }, +) + + +# The `page[...]` query parameters of `GET /messages/{messageId}/events`. +ListMessageEventsQueryPage = TypedDict( + "ListMessageEventsQueryPage", + { + # The number of results that will be returned per page. + "size": NotRequired[int], + # The cursor to start the pagination from. + "cursor": NotRequired[str], + }, +) + + +ListMessageEventsQuerySortItem: TypeAlias = ( + Literal[ + "timestamp", + "-timestamp", + "event", + "-event", + ] + | str +) + + +MessageEventData = TypedDict( + "MessageEventData", + { + "message_id": Required[str], + "event": Required["MessageEventType"], + "tag": Required[str | None], + "tags": Required["list[MessageTag]"], + "metadata": Required[dict[str, Any] | None], + "timestamp": Required[str], + }, +) + + +ListMessageEventsResponse: TypeAlias = CursorPage[MessageEventData] + + +# Query parameters of `GET /messages` (listMessages). +ListMessagesQuery = TypedDict( + "ListMessagesQuery", + { + "sort": NotRequired["list[ListMessagesQuerySortItem]"], + "filter": NotRequired["ListMessagesQueryFilter"], + "page": NotRequired["ListMessagesQueryPage"], + }, +) + + +# The `filter[...]` query parameters of `GET /messages`. +ListMessagesQueryFilter = TypedDict( + "ListMessagesQueryFilter", + { + # Search in sender, recipients, and subject + "search": NotRequired[str], + # Search by sender email (partial match) + "from_email": NotRequired[str], + # Search by subject (partial match) + "subject": NotRequired[str], + "tags": NotRequired["str | list[ListMessagesQueryFilterTagsItem]"], + "overdue": NotRequired[str], + # Filter by project ID + "project": NotRequired[str], + "type": NotRequired["MessageType"], + "status": NotRequired["MessageStatus"], + "delivery_mode": NotRequired["DeliveryMode"], + # Filter by route ID + "route_id": NotRequired[str], + # Filter by domain ID + "domain_id": NotRequired[str], + # Filter by tag name + "tag": NotRequired[str], + # Filter messages created after this date + "from_date": NotRequired[str], + # Filter messages created before this date + "to_date": NotRequired[str], + }, +) + + +ListMessagesQueryFilterTagsItem = TypedDict( + "ListMessagesQueryFilterTagsItem", + { + "name": NotRequired[str], + "value": NotRequired[str], + }, +) + + +# The `page[...]` query parameters of `GET /messages`. +ListMessagesQueryPage = TypedDict( + "ListMessagesQueryPage", + { + # Number of results per page + "size": NotRequired[int], + # Cursor for the next or previous page + "cursor": NotRequired[str], + }, +) + + +ListMessagesQuerySortItem: TypeAlias = ( + Literal[ + "type", + "-type", + "status", + "-status", + "from_email", + "-from_email", + "subject", + "-subject", + "created_at", + "-created_at", + "status_changed_at", + "-status_changed_at", + "scheduled_at", + "-scheduled_at", + ] + | str +) + + +MessageListData = TypedDict( + "MessageListData", + { + "id": Required[str], + "type": Required["MessageType"], + "status": Required["MessageStatus"], + "delivery_mode": Required["DeliveryMode"], + "sandbox_result": Required["SandboxResult | None"], + "scheduled_at": Required[str | None], + # Overall spam assessment score. Scores below 5 are clean, 5 through 10 indicate potential spam, scores above 10 and below 15 are likely spam, and 15 or higher is definite spam. Null means no assessment is available. Available on plans with Spam Insights + "spam_score": NotRequired[float | None], + "from_email": Required[str], + "from_name": Required[str | None], + "subject": Required[str | None], + "to": Required["list[MessageRecipientData] | None"], + "cc": Required["list[MessageRecipientData] | None"], + "bcc": Required["list[MessageRecipientData] | None"], + "reply_to": Required[list[str] | None], + "tag": Required[str | None], + "tags": Required["list[MessageTag]"], + "status_changed_at": Required[str | None], + "created_at": Required[str], + }, +) + + +ListMessagesResponse: TypeAlias = CursorPage[MessageListData] + + +# Query parameters of `GET /projects` (listProjects). +ListProjectsQuery = TypedDict( + "ListProjectsQuery", + { + "sort": NotRequired["list[ListProjectsQuerySortItem]"], + "page": NotRequired["ListProjectsQueryPage"], + "filter": NotRequired["ListProjectsQueryFilter"], + }, +) + + +# The `filter[...]` query parameters of `GET /projects`. +ListProjectsQueryFilter = TypedDict( + "ListProjectsQueryFilter", + { + "search": NotRequired[str], + }, +) + + +# The `page[...]` query parameters of `GET /projects`. +ListProjectsQueryPage = TypedDict( + "ListProjectsQueryPage", + { + # The number of results that will be returned per page. + "size": NotRequired[int], + # The cursor to start the pagination from. + "cursor": NotRequired[str], + }, +) + + +ListProjectsQuerySortItem: TypeAlias = Literal["name", "-name", "created_at", "-created_at"] | str + + +ProjectListData = TypedDict( + "ProjectListData", + { + "id": Required[str], + "name": Required[str], + "delivery_mode": Required["DeliveryMode"], + "smtp_enabled": Required[bool], + "routes_count": Required[int], + "domains_count": Required[int], + "last_28_days": Required["MessageStatsData"], + "created_at": Required[str], + "updated_at": Required[str], + }, +) + + +ListProjectsResponse: TypeAlias = CursorPage[ProjectListData] + + +# Query parameters of `GET /projects/{projectId}/routes` (listRoutes). +ListRoutesQuery = TypedDict( + "ListRoutesQuery", + { + "sort": NotRequired["list[ListRoutesQuerySortItem]"], + "page": NotRequired["ListRoutesQueryPage"], + "filter": NotRequired["ListRoutesQueryFilter"], + }, +) + + +# The `filter[...]` query parameters of `GET /projects/{projectId}/routes`. +ListRoutesQueryFilter = TypedDict( + "ListRoutesQueryFilter", + { + "route_type": NotRequired["RouteType"], + "is_default": NotRequired[bool], + # Search by name or slug + "search": NotRequired[str], + }, +) + + +# The `page[...]` query parameters of `GET /projects/{projectId}/routes`. +ListRoutesQueryPage = TypedDict( + "ListRoutesQueryPage", + { + # The number of results that will be returned per page. + "size": NotRequired[int], + # The cursor to start the pagination from. + "cursor": NotRequired[str], + }, +) + + +ListRoutesQuerySortItem: TypeAlias = ( + Literal[ + "name", + "-name", + "slug", + "-slug", + "created_at", + "-created_at", + ] + | str +) + + +RouteListData = TypedDict( + "RouteListData", + { + "id": Required[str], + "slug": Required[str], + "name": Required[str], + "route_type": Required["RouteType"], + "is_default": Required[bool], + "webhooks_count": Required[int], + "suppressed_recipients_count": Required[int], + "created_at": Required[str], + "updated_at": Required[str], + }, +) + + +ListRoutesResponse: TypeAlias = CursorPage[RouteListData] + + +# Query parameters of `GET /suppressions` (listSuppressions). +ListSuppressionsQuery = TypedDict( + "ListSuppressionsQuery", + { + "sort": NotRequired["list[ListSuppressionsQuerySortItem]"], + "page": NotRequired["ListSuppressionsQueryPage"], + "filter": NotRequired["ListSuppressionsQueryFilter"], + }, +) + + +# The `filter[...]` query parameters of `GET /suppressions`. +ListSuppressionsQueryFilter = TypedDict( + "ListSuppressionsQueryFilter", + { + # Filter by scope (team, project, route) + "scope": NotRequired[str], + # Filter by route ID + "route_id": NotRequired[str], + # Filter by project ID + "project_id": NotRequired[str], + # Search by email/domain value (partial match) + "value": NotRequired[str], + "reason": NotRequired["SuppressionReason"], + # Filter suppressions created or updated on or after this ISO 8601 date or datetime + "startDate": NotRequired[str], + # Filter suppressions created or updated on or before this ISO 8601 date or datetime + "endDate": NotRequired[str], + }, +) + + +# The `page[...]` query parameters of `GET /suppressions`. +ListSuppressionsQueryPage = TypedDict( + "ListSuppressionsQueryPage", + { + # The number of results that will be returned per page. + "size": NotRequired[int], + # The cursor to start the pagination from. + "cursor": NotRequired[str], + }, +) + + +ListSuppressionsQuerySortItem: TypeAlias = ( + Literal[ + "value", + "-value", + "created_at", + "-created_at", + "reason", + "-reason", + ] + | str +) + + +SuppressedRecipientData = TypedDict( + "SuppressedRecipientData", + { + "id": Required[str], + "type": Required["SuppressionType"], + "value": Required[str], + "reason": Required["SuppressionReason"], + "scope": Required["SuppressionScope"], + "applies_to": Required["SuppressionAppliesTo"], + "project_id": Required[str | None], + "route_id": Required[str | None], + "source_message": NotRequired["SuppressionSourceMessageData | None"], + "created_at": Required[str], + }, +) + + +ListSuppressionsResponse: TypeAlias = CursorPage[SuppressedRecipientData] + + +# Query parameters of `GET /team/members` (listTeamMembers). +ListTeamMembersQuery = TypedDict( + "ListTeamMembersQuery", + { + "page": NotRequired["ListTeamMembersQueryPage"], + }, +) + + +# The `page[...]` query parameters of `GET /team/members`. +ListTeamMembersQueryPage = TypedDict( + "ListTeamMembersQueryPage", + { + # The number of results that will be returned per page. + "size": NotRequired[int], + # The cursor to start the pagination from. + "cursor": NotRequired[str], + }, +) + + +TeamMemberData = TypedDict( + "TeamMemberData", + { + "id": Required[str], + "name": Required[str], + "email": Required[str], + "role": Required["TeamMemberDataRole"], + "project_access": Required["TeamMemberProjectAccessData"], + "joined_at": Required[str | None], + }, +) + + +ListTeamMembersResponse: TypeAlias = CursorPage[TeamMemberData] + + +# Query parameters of `GET /webhooks/{webhookId}/deliveries` (listWebhookDeliveries). +ListWebhookDeliveriesQuery = TypedDict( + "ListWebhookDeliveriesQuery", + { + # Cursor for the next or previous page. + "cursor": NotRequired[str], + "sort": NotRequired["list[ListWebhookDeliveriesQuerySortItem]"], + "filter": NotRequired["ListWebhookDeliveriesQueryFilter"], + }, +) + + +# The `filter[...]` query parameters of `GET /webhooks/{webhookId}/deliveries`. +ListWebhookDeliveriesQueryFilter = TypedDict( + "ListWebhookDeliveriesQueryFilter", + { + # Filter by delivery status + "status": NotRequired["WebhookDeliveryStatus"], + # Filter by event type + "event_type": NotRequired[str], + # Filter deliveries from this date (Y-m-d format) + "from_date": NotRequired[str], + # Filter deliveries to this date (Y-m-d format) + "to_date": NotRequired[str], + }, +) + + +ListWebhookDeliveriesQuerySortItem: TypeAlias = ( + Literal[ + "created_at", + "-created_at", + "attempt_number", + "-attempt_number", + ] + | str +) + + +WebhookDeliveryListData = TypedDict( + "WebhookDeliveryListData", + { + "id": Required[str], + "webhook_id": Required[str], + "event_type": Required["WebhookEvent"], + "source_scope": Required[str | None], + "source_project_id": Required[str | None], + "source_route_id": Required[str | None], + "status": Required["WebhookDeliveryStatus"], + "sandbox": Required[bool], + "attempt_number": Required[int], + "http_status_code": Required[int | None], + "duration_ms": Required[int | None], + "delivered_at": Required[str | None], + "created_at": Required[str], + }, +) + + +ListWebhookDeliveriesResponse: TypeAlias = CursorPage[WebhookDeliveryListData] + + +# Query parameters of `GET /webhooks` (listWebhooks). +ListWebhooksQuery = TypedDict( + "ListWebhooksQuery", + { + # Cursor for the next or previous page. + "cursor": NotRequired[str], + "sort": NotRequired["list[ListWebhooksQuerySortItem]"], + "page": NotRequired["ListWebhooksQueryPage"], + "filter": NotRequired["ListWebhooksQueryFilter"], + }, +) + + +# The `filter[...]` query parameters of `GET /webhooks`. +ListWebhooksQueryFilter = TypedDict( + "ListWebhooksQueryFilter", + { + # Filter by project ID + "project": NotRequired[str], + # Filter by enabled status + "enabled": NotRequired[bool], + # Filter by specific event type + "event": NotRequired["WebhookEvent"], + # Filter by route ID + "route_id": NotRequired[str], + # Filter by webhook scope + "scope": NotRequired["WebhookScope"], + # Search by webhook name or URL + "search": NotRequired[str], + }, +) + + +# The `page[...]` query parameters of `GET /webhooks`. +ListWebhooksQueryPage = TypedDict( + "ListWebhooksQueryPage", + { + "size": NotRequired[int], + }, +) + + +ListWebhooksQuerySortItem: TypeAlias = ( + Literal[ + "name", + "-name", + "url", + "-url", + "created_at", + "-created_at", + ] + | str +) + + +WebhookListData = TypedDict( + "WebhookListData", + { + "id": Required[str], + "scope": Required["WebhookScope"], + "project_ids": Required[list[str]], + "route_ids": Required[list[str]], + # Use route_ids. + # Deprecated. + "route_id": Required[str | None], + "name": Required[str], + "url": Required[str], + "has_basic_auth": Required[bool], + "events": Required[list[str]], + "enabled": Required[bool], + "delivery_mode_filter": Required["WebhookDeliveryModeFilter"], + "last_called_at": Required[str | None], + "created_at": Required[str], + "updated_at": Required[str], + }, +) + + +ListWebhooksResponse: TypeAlias = CursorPage[WebhookListData] + + +MessageAttachmentData = TypedDict( + "MessageAttachmentData", + { + "size": Required[int], + "filename": Required[str], + "content_id": Required[str | None], + "content_type": Required[str], + }, +) + + +# A file attached to the message. +MessageAttachmentInput = TypedDict( + "MessageAttachmentInput", + { + "filename": Required[str], + "content": Required[str], + # The MIME type of the attachment. Supports parameters (e.g. `text/calendar; method=REQUEST`). + # If omitted, detected automatically from content or filename. + "content_type": NotRequired[str | None], + # Content ID for inline attachments, referenced via `cid:` in HTML body. + # If no `@` is present, `@lm` is appended automatically. + "content_id": NotRequired[str | None], + }, +) + + +MessageData = TypedDict( + "MessageData", + { + "id": Required[str], + "type": Required["MessageType"], + "status": Required["MessageStatus"], + "delivery_mode": Required["DeliveryMode"], + "sandbox_result": Required["SandboxResult | None"], + "status_changed_at": Required[str | None], + "scheduled_at": Required[str | None], + "tag": Required[str | None], + "tags": Required["list[MessageTag]"], + "from_email": Required[str], + "from_name": Required[str | None], + "reply_to": Required[list[str] | None], + "subject": Required[str | None], + "to": Required["list[MessageRecipientData] | None"], + "cc": Required["list[MessageRecipientData] | None"], + "bcc": Required["list[MessageRecipientData] | None"], + "attachments": Required["list[MessageAttachmentData] | None"], + "metadata": Required[dict[str, str] | None], + # Overall spam assessment score. Scores below 5 are clean, 5 through 10 indicate potential spam, scores above 10 and below 15 are likely spam, and 15 or higher is definite spam. Null means no assessment is available. Available on plans with Spam Insights + "spam_score": NotRequired[float | None], + # Detailed rules that contributed to the spam assessment. Available on plans with Spam Insights in individual-message responses + "spam_symbols": NotRequired["list[SpamSymbol]"], + "route_id": Required[str], + "created_at": Required[str], + }, +) + + +MessageEventType: TypeAlias = ( + Literal[ + "scheduled", + "rescheduled", + "canceled", + "released", + "queued", + "processed", + "suppressed", + "delivered", + "auto_replied", + "soft_bounced", + "hard_bounced", + "spam_complaint", + "failed", + "blocked", + "policy_rejected", + "unsubscribed", + "opened", + "clicked", + "inbound_received", + "inbound_queued", + "inbound_spam_blocked", + "inbound_released", + "inbound_processed", + "inbound_retry", + ] + | str +) + + +MessageRecipientData = TypedDict( + "MessageRecipientData", + { + "email": Required[str], + "name": Required[str | None], + # The simulated result for this recipient in a Sandbox project. Lettermint test addresses override the message result. Null for Live messages + "sandbox_result": Required["SandboxResult | None"], + }, +) + + +# Confirmation that a request succeeded. +MessageResponse = TypedDict( + "MessageResponse", + { + # Human-readable confirmation + "message": Required[str], + }, +) + + +MessageStatsData = TypedDict( + "MessageStatsData", + { + "messages_transactional": Required[int], + "messages_broadcast": Required[int], + "messages_inbound": Required[int], + "deliverability": Required[float], + }, +) + + +MessageStatus: TypeAlias = ( + Literal[ + "scheduled", + "pending", + "queued", + "quarantined", + "suppressed", + "processed", + "delivered", + "opened", + "clicked", + "soft_bounced", + "hard_bounced", + "spam_complaint", + "failed", + "blocked", + "policy_rejected", + "unsubscribed", + "canceled", + ] + | str +) + + +MessageTag = TypedDict( + "MessageTag", + { + "name": Required[str], + "value": Required[str], + }, +) + + +# A reusable exact-match tag with a name and a value. +MessageTagInput = TypedDict( + "MessageTagInput", + { + "name": Required[str], + "value": Required[str], + }, +) + + +MessageType: TypeAlias = Literal["inbound", "outbound"] | str + + +# Not found +ModelNotFoundException: TypeAlias = ErrorMessage + + +# An accepted message that is queued for delivery. +PendingSendMailResponse = TypedDict( + "PendingSendMailResponse", + { + "message_id": Required[str], + "status": Required[Literal['pending']], + # Present when the project uses Sandbox delivery + "sandbox": NotRequired[Literal[True]], + # The simulated delivery result. Present when the project uses Sandbox delivery + "sandbox_result": NotRequired["SandboxResult"], + }, +) + + +Plan: TypeAlias = Literal["free", "starter", "growth", "pro"] | str + + +ProcessInboundMessageConflict = TypedDict( + "ProcessInboundMessageConflict", + { + "error": Required["ProcessInboundMessageConflictError"], + }, +) + + +ProcessInboundMessageConflictError = TypedDict( + "ProcessInboundMessageConflictError", + { + "code": Required["ProcessInboundMessageConflictErrorCode"], + "message": Required[str], + }, +) + + +ProcessInboundMessageConflictErrorCode: TypeAlias = ( + Literal[ + "INBOUND_MESSAGE_NOT_QUARANTINED", + "INBOUND_WEBHOOK_NOT_CONFIGURED", + "MESSAGE_SOURCE_UNAVAILABLE", + ] + | str +) + + +# The queued release of a quarantined inbound message. +ProcessInboundMessageResponse = TypedDict( + "ProcessInboundMessageResponse", + { + "data": Required["ProcessInboundMessageResult"], + }, +) + + +# A quarantined inbound message that was queued for webhook delivery. +ProcessInboundMessageResult = TypedDict( + "ProcessInboundMessageResult", + { + "message_id": Required[str], + "status": Required[Literal['queued']], + # The number of webhooks the message is delivered to + "webhook_target_count": Required[int], + }, +) + + +ProjectAccessScope: TypeAlias = Literal["all", "selected"] | str + + +ProjectCreatedData = TypedDict( + "ProjectCreatedData", + { + "data": Required["ProjectData"], + "message": Required[str], + "api_token": NotRequired[str], + }, +) + + +ProjectData = TypedDict( + "ProjectData", + { + "id": Required[str], + "name": Required[str], + "delivery_mode": Required["DeliveryMode"], + "smtp_enabled": Required[bool], + "redact_email_content": Required[bool], + "default_route_id": Required[str | None], + "token_generated_at": Required[str | None], + "token_last_used_at": Required[str | None], + "token_last_used_ip": Required[str | None], + "routes": NotRequired["list[RouteData]"], + "routes_count": NotRequired[int], + "domains": NotRequired["list[DomainData]"], + "domains_count": NotRequired[int], + "last_28_days": NotRequired["MessageStatsData | None"], + "created_at": Required[str], + "updated_at": Required[str], + }, +) + + +# The project after a change, with a confirmation message. +ProjectMutationResponse = TypedDict( + "ProjectMutationResponse", + { + "data": Required["ProjectData"], + # Human-readable confirmation + "message": Required[str], + }, +) + + +RbacConflictCode: TypeAlias = ( + Literal[ + "stale_resource", + "owner_protected", + "last_owner", + "built_in_role_immutable", + "custom_role_requires_pro", + ] + | str +) + + +# Role or project-access state conflict +RbacConflictException = TypedDict( + "RbacConflictException", + { + "error": Required["RbacConflictExceptionError"], + }, +) + + +RbacConflictExceptionError = TypedDict( + "RbacConflictExceptionError", + { + "code": Required["RbacConflictCode"], + "message": Required[str], + }, +) + + +RbacPermission: TypeAlias = ( + Literal[ + "team:manage", + "billing:manage", + "security:manage", + "audit:read", + "support:manage", + "members:read", + "members:manage", + "roles:manage", + "team_tokens:read", + "team_tokens:manage", + "team_tokens:rotate", + "team_tokens:revoke", + "projects:create", + "team_suppressions:read", + "team_suppressions:add", + "team_suppressions:remove", + "projects:read", + "projects:manage", + "projects:delete", + "routes:read", + "routes:manage", + "routes:delete", + "domains:read", + "domains:manage", + "domains:delete", + "project_tokens:read", + "project_tokens:manage", + "project_tokens:rotate", + "project_tokens:revoke", + "webhooks:read", + "webhooks:manage", + "webhooks:delete", + "webhooks:rotate_secret", + "stats:read", + "analytics:read", + "messages:read", + "messages:read_content", + "messages:send", + "suppressions:read", + "suppressions:add", + "suppressions:remove", + ] + | str +) + + +RecordType: TypeAlias = Literal["TXT", "CNAME", "MX"] | str + + +ReportForwardingRequest = TypedDict( + "ReportForwardingRequest", + { + "destination": Required[str], + }, +) + + +ReportForwardingResource = TypedDict( + "ReportForwardingResource", + { + "destination": Required[str | None], + "verified": Required[bool], + "verified_at": Required[str | None], + }, +) + + +RescheduleMessageRequest = TypedDict( + "RescheduleMessageRequest", + { + "scheduled_at": Required[str], + }, +) + + +ResendReportForwardingCodeResponse = TypedDict( + "ResendReportForwardingCodeResponse", + { + "data": Required["ReportForwardingResource"], + }, +) + + +# The project and its new legacy Project API token. +RotateProjectTokenResponse = TypedDict( + "RotateProjectTokenResponse", + { + "data": Required["ProjectData"], + # The new Project API token. It is only returned once + "new_token": Required[str], + # Human-readable confirmation + "message": Required[str], + }, +) + + +RouteData = TypedDict( + "RouteData", + { + "id": Required[str], + "project_id": Required[str], + "slug": Required[str], + "name": Required[str], + "route_type": Required["RouteType"], + "is_default": Required[bool], + # Permanent compatibility address for this inbound route (`@inbound.lettermint.co`). It keeps working alongside the catch-all route domain + "inbound_address": NotRequired[str | None], + # Catch-all receiving domain for this inbound route. Mail to any address at this domain reaches the route. Null for non-inbound routes + "inbound_route_domain": NotRequired[str | None], + "inbound_mx_hostname": NotRequired[str], + "inbound_domain": NotRequired[str | None], + "inbound_domain_verified_at": NotRequired[str | None], + "inbound_spam_threshold": NotRequired[float | None], + "attachment_delivery": NotRequired["AttachmentDelivery"], + # Route settings available for this route type and team feature access + "settings": NotRequired["RouteDataSettings | None"], + "project": NotRequired["ProjectData"], + "webhooks_count": NotRequired[int], + "suppressed_recipients_count": NotRequired[int], + "statistics": NotRequired["list[RouteStatisticData]"], + "created_at": Required[str], + "updated_at": Required[str], + }, +) + + +# Route settings available for this route type and team feature access +RouteDataSettings = TypedDict( + "RouteDataSettings", + { + "disable_hosted_unsubscribe": NotRequired[bool], + "track_opens": NotRequired[bool], + "track_clicks": NotRequired[bool], + "generate_plaintext_fallback": NotRequired[bool], + "suppress_auto_responders": NotRequired[bool], + "suppress_disposable_recipients": NotRequired[bool], + "tls": NotRequired["TlsPolicy"], + "redact_email_content": NotRequired[bool], + "attachment_delivery": NotRequired["AttachmentDelivery"], + }, +) + + +# The route after it was created or changed, with a confirmation message. +RouteMutationResponse = TypedDict( + "RouteMutationResponse", + { + "data": Required["RouteData"], + # Human-readable confirmation + "message": Required[str], + }, +) + + +RouteStatisticData = TypedDict( + "RouteStatisticData", + { + "date": Required[str], + "sent_count": Required[int], + "delivered_count": Required[int], + "opened_count": Required[int], + "clicked_count": Required[int], + "hard_bounce_count": Required[int], + "spam_complaint_count": Required[int], + "inbound_received_count": Required[int], + "observed_opened_count": NotRequired[int | None], + "human_opened_count": NotRequired[int | None], + "privacy_opened_count": NotRequired[int | None], + "effective_opened_count": Required[int | None], + "machine_opened_count": Required[int | None], + "machine_clicked_count": Required[int | None], + }, +) + + +RouteType: TypeAlias = Literal["transactional", "broadcast", "inbound"] | str + + +SandboxResult: TypeAlias = ( + Literal[ + "delivered", + "hard_bounced", + "soft_bounced", + "deferred", + "failed", + "suppressed", + "spam_complaint", + "auto_replied", + "opened", + "clicked", + "unsubscribed", + ] + | str +) + + +# The schedule state of a message after it was rescheduled or canceled. +ScheduledMessage = TypedDict( + "ScheduledMessage", + { + "message_id": Required[str], + "status": Required["MessageStatus | None"], + # The scheduled delivery time in ISO 8601 format + "scheduled_at": Required[str | None], + }, +) + + +# An accepted message that is scheduled for later delivery. +ScheduledSendMailResponse = TypedDict( + "ScheduledSendMailResponse", + { + "message_id": Required[str], + "status": Required[Literal['scheduled']], + # The scheduled delivery time in ISO 8601 format + "scheduled_at": Required[str], + # Present when the project uses Sandbox delivery + "sandbox": NotRequired[Literal[True]], + # The simulated delivery result. Present when the project uses Sandbox delivery + "sandbox_result": NotRequired["SandboxResult"], + }, +) + + +SendMailRequest = TypedDict( + "SendMailRequest", + { + "route": NotRequired[str], + "from": Required[str], + "to": Required[list[str]], + "cc": NotRequired[list[str]], + "bcc": NotRequired[list[str]], + "reply_to": NotRequired[list[str]], + "subject": Required[str], + # ISO 8601 or English delivery time. A time without a timezone uses UTC. + "scheduled_at": NotRequired[str], + # The result that a Sandbox project simulates for every recipient. + "sandbox_result": NotRequired["SandboxResult"], + # 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. + "headers": NotRequired[dict[str, str]], + # Metadata to track with the email (not added as email headers). + "metadata": NotRequired[dict[str, str]], + # Tag to categorize and filter emails (alphanumeric, underscores, hyphens, spaces allowed). + "tag": NotRequired[str | None], + # Reusable exact-match tags. Names must be unique and are case-sensitive. + "tags": NotRequired["list[MessageTagInput]"], + # Per-email settings that override the selected route settings for this email only. + "settings": NotRequired["SendMailRequestSettings"], + "html": NotRequired[str | None], + "text": NotRequired[str | None], + "attachments": NotRequired["list[MessageAttachmentInput]"], + }, +) + + +SendBatchMailRequest: TypeAlias = list[SendMailRequest] + + +SendMailResponse: TypeAlias = PendingSendMailResponse | ScheduledSendMailResponse + + +SendBatchMailResponse: TypeAlias = list[SendMailResponse] + + +# Per-email settings that override the selected route settings for this email only. +SendMailRequestSettings = TypedDict( + "SendMailRequestSettings", + { + "track_opens": NotRequired[bool], + "track_clicks": NotRequired[bool], + "tls": NotRequired["TlsPolicy"], + }, +) + + +SpamSymbol = TypedDict( + "SpamSymbol", + { + "name": Required[str], + "score": Required[float], + "options": Required[list[str]], + "description": Required[str | None], + }, +) + + +StatsDailyData = TypedDict( + "StatsDailyData", + { + "date": Required[str], + "sent": Required[int], + "delivered": Required[int], + "hard_bounced": Required[int], + "spam_complaints": Required[int], + # Null when tracking is not enabled for this context + "opened": Required[int | None], + # Null when tracking is not enabled for this context + "clicked": Required[int | None], + "inbound": Required["StatsInboundData"], + # Null for team-scoped stats (only available when project_id is specified) + "transactional": Required["StatsTypeData | None"], + # Null for team-scoped stats (only available when project_id is specified) + "broadcast": Required["StatsTypeData | None"], + # Human opens plus unresolved privacy-proxy opens + "observed_opened": NotRequired[int | None], + "human_opened": NotRequired[int | None], + "privacy_opened": NotRequired[int | None], + "effective_opened": Required[int | None], + "machine_opened": Required[int | None], + "machine_clicked": Required[int | None], + }, +) + + +StatsData = TypedDict( + "StatsData", + { + "from": Required[str], + "to": Required[str], + "totals": Required["StatsTotalsData"], + "daily": Required["list[StatsDailyData]"], + }, +) + + +StatsInboundData = TypedDict( + "StatsInboundData", + { + "received": Required[int], + }, +) + + +StatsTotalsData = TypedDict( + "StatsTotalsData", + { + "sent": Required[int], + "delivered": Required[int], + "hard_bounced": Required[int], + "spam_complaints": Required[int], + # Null when tracking is not enabled for this context + "opened": Required[int | None], + # Null when tracking is not enabled for this context + "clicked": Required[int | None], + "inbound": Required["StatsInboundData"], + # Null for team-scoped stats (only available when project_id is specified) + "transactional": Required["StatsTypeData | None"], + # Null for team-scoped stats (only available when project_id is specified) + "broadcast": Required["StatsTypeData | None"], + # Human opens plus unresolved privacy-proxy opens + "observed_opened": NotRequired[int | None], + "human_opened": NotRequired[int | None], + "privacy_opened": NotRequired[int | None], + "effective_opened": Required[int | None], + "machine_opened": Required[int | None], + "machine_clicked": Required[int | None], + }, +) + + +StatsTypeData = TypedDict( + "StatsTypeData", + { + "sent": Required[int], + "hard_bounced": Required[int], + "spam_complaints": Required[int], + }, +) + + +StoreDomainData = TypedDict( + "StoreDomainData", + { + "domain": Required[str], + }, +) + + +StoreProjectData = TypedDict( + "StoreProjectData", + { + "name": Required[str], + "smtp_enabled": NotRequired[bool], + "delivery_mode": NotRequired["DeliveryMode"], + "initial_routes": NotRequired["InitialRoutes"], + "short_token": NotRequired[bool], + "redact_email_content": NotRequired[bool], + }, +) + + +StoreRouteData = TypedDict( + "StoreRouteData", + { + "name": Required[str], + "route_type": Required["RouteType"], + "slug": NotRequired[str | None], + "settings": NotRequired["UpdateRouteSettingsData | None"], + "inbound_settings": NotRequired["UpdateRouteInboundSettingsData | None"], + "inbound_domain": NotRequired[str | None], + "inbound_spam_threshold": NotRequired[float | None], + "attachment_delivery": NotRequired["AttachmentDelivery | None"], + }, +) + + +StoreSuppressionData = TypedDict( + "StoreSuppressionData", + { + "email": NotRequired[str | None], + "emails": NotRequired[list[str] | None], + "reason": Required["SuppressionCreateReason"], + "scope": Required["SuppressionCreateScope"], + "route_id": NotRequired[str | None], + "project_id": NotRequired[str | None], + # Mail category to block. Manual suppressions default to `all`. Use `broadcast` only at team scope, project scope, or on a broadcast route + "applies_to": NotRequired["SuppressionAppliesTo | None"], + }, +) + + +StoreWebhookData = TypedDict( + "StoreWebhookData", + { + "name": Required[str], + "url": Required[str], + "events": Required["list[WebhookEvent]"], + "enabled": NotRequired[bool | None], + "include_machine_events": NotRequired[bool | None], + "delivery_mode_filter": NotRequired["WebhookDeliveryModeFilter | None"], + "scope": NotRequired["WebhookScope | None"], + "project_ids": NotRequired[list[str]], + "route_ids": NotRequired[list[str]], + "route_id": NotRequired[str | None], + # Write-only credentials. Omit to keep credentials. Null removes Basic Auth + "basic_auth": NotRequired["WebhookBasicAuthData | None"], + }, +) + + +# Mail category blocked by the suppression. `all` blocks transactional and broadcast mail. `broadcast` blocks broadcast mail only. +SuppressionAppliesTo: TypeAlias = Literal["all", "broadcast"] | str + + +# Reason that the recipient is suppressed. The `disposable_email` reason is reserved for route policies. +SuppressionCreateReason: TypeAlias = ( + Literal[ + "spam_complaint", + "hard_bounce", + "unsubscribe", + "manual", + ] + | str +) + + +# Where the suppression applies. The `global` and `subscription_group` scopes cannot be created through the API. +SuppressionCreateScope: TypeAlias = Literal["team", "project", "route"] | str + + +# Reason that the recipient is suppressed. +SuppressionReason: TypeAlias = ( + Literal[ + "spam_complaint", + "hard_bounce", + "unsubscribe", + "manual", + "disposable_email", + ] + | str +) + + +SuppressionReviewResponseStatus: TypeAlias = ( + Literal[ + "review_ticket_created", + "review_ticket_exists", + ] + | str +) + + +# Where the suppression applies. +SuppressionScope: TypeAlias = ( + Literal[ + "global", + "team", + "project", + "route", + "subscription_group", + ] + | str +) + + +SuppressionSourceMessageData = TypedDict( + "SuppressionSourceMessageData", + { + "id": Required[str], + "available": Required[bool], + "subject": Required[str | None], + "created_at": Required[str | None], + }, +) + + +# The outcome of adding addresses to the suppression list. +SuppressionStoreResponse = TypedDict( + "SuppressionStoreResponse", + { + # Human-readable summary + "message": Required[str], + "data": Required["SuppressionStoreResult"], + }, +) + + +# The addresses that were added and the addresses that were already suppressed. +SuppressionStoreResult = TypedDict( + "SuppressionStoreResult", + { + "created": Required[list[str]], + "skipped": Required[list[str]], + }, +) + + +SuppressionType: TypeAlias = Literal["email", "domain", "extension"] | str + + +TeamAddonData = TypedDict( + "TeamAddonData", + { + "type": Required[str | None], + "expires_at": Required[str | None], + }, +) + + +TeamData = TypedDict( + "TeamData", + { + "id": Required[str], + "name": Required[str], + "type": Required["TeamType"], + "plan": Required["Plan"], + # Number of emails included in the team's monthly plan + "included_volume": Required[int], + # Deprecated alias for `included_volume` + # Deprecated. + "tier": Required[int], + "verified_at": Required[str | None], + "features": NotRequired[list[str]], + "addons": NotRequired["list[TeamAddonData]"], + "created_at": Required[str], + "domains_count": NotRequired[int], + "projects_count": NotRequired[int], + "members_count": NotRequired[int], + }, +) + + +TeamMemberDataRole = TypedDict( + "TeamMemberDataRole", + { + "id": Required[str], + "name": Required[str], + }, +) + + +TeamMemberProjectAccessData = TypedDict( + "TeamMemberProjectAccessData", + { + "scope": Required["ProjectAccessScope"], + "projects": Required["list[TeamMemberProjectAccessDataProjectsItem]"], + }, +) + + +TeamMemberProjectAccessDataProjectsItem = TypedDict( + "TeamMemberProjectAccessDataProjectsItem", + { + "id": Required[str], + "name": Required[str], + }, +) + + +# The team after a settings change, with a confirmation message. +TeamMutationResponse = TypedDict( + "TeamMutationResponse", + { + "data": Required["TeamData"], + # Human-readable confirmation + "message": Required[str], + }, +) + + +TeamRoleData = TypedDict( + "TeamRoleData", + { + "id": Required[str], + "name": Required[str], + "system_key": Required["BuiltInTeamRole | None"], + "permissions": Required["list[RbacPermission]"], + "assignable": Required[bool], + }, +) + + +# The reusable roles of the team. +TeamRoleListResponse = TypedDict( + "TeamRoleListResponse", + { + "data": Required["list[TeamRoleData]"], + }, +) + + +TeamType: TypeAlias = Literal["personal", "business"] | str + + +TeamUsageDetailData = TypedDict( + "TeamUsageDetailData", + { + "current_period": Required["TeamUsagePeriodData"], + "historical_usage": Required["list[TeamUsagePeriodData]"], + }, +) + + +TeamUsagePeriodData = TypedDict( + "TeamUsagePeriodData", + { + "usage": Required[int], + "last_incremented_at": Required[str | None], + "period_start": Required[str], + "period_end": Required[str], + }, +) + + +# Confirmation that a test delivery was queued. +TestWebhookResponse = TypedDict( + "TestWebhookResponse", + { + # Human-readable confirmation + "message": Required[str], + # The ID of the queued test delivery + "delivery_id": Required[str], + }, +) + + +TlsPolicy: TypeAlias = Literal["opportunistic", "enforced"] | str + + +# Too many requests. +TooManyRequests: TypeAlias = ErrorMessage + + +UpdateDomainProjectsData = TypedDict( + "UpdateDomainProjectsData", + { + "project_ids": Required[list[str]], + }, +) + + +UpdateProjectData = TypedDict( + "UpdateProjectData", + { + "name": NotRequired[str | None], + "smtp_enabled": NotRequired[bool | None], + "redact_email_content": NotRequired[bool | None], + "delivery_mode": NotRequired["DeliveryMode | None"], + "default_route_id": NotRequired[str | None], + }, +) + + +UpdateReportForwardingResponse = TypedDict( + "UpdateReportForwardingResponse", + { + "data": Required["ReportForwardingResource"], + }, +) + + +UpdateRouteData = TypedDict( + "UpdateRouteData", + { + "name": NotRequired[str | None], + # Route settings. `track_opens` and `track_clicks` apply to transactional and broadcast routes when email tracking is available. `generate_plaintext_fallback` opts in to generated plaintext fallbacks for HTML-only outbound messages; customer-provided plaintext is always preserved. `suppress_auto_responders` adds outbound headers that suppress auto-responders. `suppress_disposable_recipients` skips matching recipients for each message without adding them to suppression lists. `tls` controls whether TLS is opportunistic or enforced. `disable_hosted_unsubscribe` applies to broadcast routes when enabled for the team. `redact_email_content` requires email redaction access + "settings": NotRequired["UpdateRouteSettingsData | None"], + # Inbound route settings. Only accepted for inbound routes + "inbound_settings": NotRequired["UpdateRouteInboundSettingsData | None"], + "inbound_domain": NotRequired[str | None], + "inbound_spam_threshold": NotRequired[float | None], + "attachment_delivery": NotRequired["AttachmentDelivery | None"], + }, +) + + +UpdateRouteInboundSettingsData = TypedDict( + "UpdateRouteInboundSettingsData", + { + # Custom receiving domain for inbound routes, such as support.example.com or *.m.example.com. Address patterns such as *@*.m.example.com are normalized + "inbound_domain": NotRequired[str | None], + # Inbound spam threshold for inbound routes. Higher values are more permissive + "inbound_spam_threshold": NotRequired[float | None], + # Inbound attachment delivery mode + "attachment_delivery": NotRequired["AttachmentDelivery | None"], + }, +) + + +UpdateRouteSettingsData = TypedDict( + "UpdateRouteSettingsData", + { + # Enable open tracking for transactional and broadcast routes. Requires email tracking access + "track_opens": NotRequired[bool | None], + # Enable click tracking for transactional and broadcast routes. Requires email tracking access + "track_clicks": NotRequired[bool | None], + # Opt in to generated plaintext fallbacks for HTML-only outbound messages on transactional and broadcast routes. Customer-provided plaintext is always preserved + "generate_plaintext_fallback": NotRequired[bool | None], + # Add outbound headers that suppress auto-responders for transactional and broadcast routes. Customer-provided header values are preserved + "suppress_auto_responders": NotRequired[bool | None], + # Skip disposable email recipients for transactional and broadcast routes. This check applies to each message and does not add recipients to suppression lists + "suppress_disposable_recipients": NotRequired[bool | None], + # TLS delivery policy for transactional and broadcast routes + "tls": NotRequired["TlsPolicy | None"], + # Disable Lettermint-hosted unsubscribe link injection for broadcast routes. Available only when enabled for the team + "disable_hosted_unsubscribe": NotRequired[bool | None], + # Redact email content in the dashboard for this route. Requires email redaction access + "redact_email_content": NotRequired[bool | None], + }, +) + + +UpdateTeamData = TypedDict( + "UpdateTeamData", + { + "name": NotRequired[str], + }, +) + + +UpdateTeamMemberAssignmentData = TypedDict( + "UpdateTeamMemberAssignmentData", + { + "role_id": Required[str], + "project_access": Required["UpdateTeamMemberAssignmentDataProjectAccess"], + }, +) + + +UpdateTeamMemberAssignmentDataProjectAccess = TypedDict( + "UpdateTeamMemberAssignmentDataProjectAccess", + { + "scope": Required["ProjectAccessScope"], + "project_ids": NotRequired[list[str]], + }, +) + + +UpdateWebhookData = TypedDict( + "UpdateWebhookData", + { + "name": NotRequired[str], + "url": NotRequired[str], + "events": NotRequired["list[WebhookEvent]"], + "enabled": NotRequired[bool], + "include_machine_events": NotRequired[bool], + "delivery_mode_filter": NotRequired["WebhookDeliveryModeFilter"], + "scope": NotRequired["WebhookScope"], + "project_ids": NotRequired[list[str]], + "route_ids": NotRequired[list[str]], + "route_id": NotRequired[str | None], + # Write-only credentials. Omit to keep credentials. Null removes Basic Auth + "basic_auth": NotRequired["WebhookBasicAuthData | None"], + }, +) + + +ValidationErrorBody = TypedDict( + "ValidationErrorBody", + { + # Errors overview. + "message": Required[str], + # A detailed description of each field that failed validation. + "errors": Required[dict[str, list[str]]], + }, +) + + +# Validation error +ValidationException: TypeAlias = ValidationErrorBody + + +VerifyReportForwardingRequest = TypedDict( + "VerifyReportForwardingRequest", + { + "code": Required[str], + }, +) + + +VerifyReportForwardingResponse = TypedDict( + "VerifyReportForwardingResponse", + { + "data": Required["ReportForwardingResource"], + }, +) + + +WebhookBasicAuthData = TypedDict( + "WebhookBasicAuthData", + { + "username": Required[str], + "password": Required[str], + }, +) + + +WebhookData = TypedDict( + "WebhookData", + { + "id": Required[str], + "scope": Required["WebhookScope"], + "project_ids": Required[list[str]], + "route_ids": Required[list[str]], + # Use route_ids. + # Deprecated. + "route_id": Required[str | None], + "name": Required[str], + "url": Required[str], + "has_basic_auth": Required[bool], + "events": Required[list[str]], + "enabled": Required[bool], + "include_machine_events": Required[bool], + "delivery_mode_filter": Required["WebhookDeliveryModeFilter"], + "last_called_at": Required[str | None], + "created_at": Required[str], + "updated_at": Required[str], + }, +) + + +WebhookDeliveryData = TypedDict( + "WebhookDeliveryData", + { + "id": Required[str], + "webhook_id": Required[str], + "event_type": Required["WebhookEvent"], + "source_scope": Required[str | None], + "source_project_id": Required[str | None], + "source_route_id": Required[str | None], + "status": Required["WebhookDeliveryStatus"], + "sandbox": Required[bool], + "attempt_number": Required[int], + "http_status_code": Required[int | None], + "duration_ms": Required[int | None], + "payload": Required[list[str]], + "response_body": Required[str | None], + "response_headers": Required[list[str] | None], + "error_message": Required[str | None], + "delivered_at": Required[str | None], + "timestamp": Required[str], + }, +) + + +WebhookDeliveryModeFilter: TypeAlias = Literal["live", "sandbox", "both"] | str + + +WebhookDeliveryStatus: TypeAlias = ( + Literal[ + "pending", + "success", + "failed", + "client_error", + "server_error", + "timeout", + ] + | str +) + + +WebhookEvent: TypeAlias = ( + Literal[ + "message.created", + "message.sent", + "message.delivered", + "message.auto_replied", + "message.hard_bounced", + "message.soft_bounced", + "message.spam_complaint", + "message.failed", + "message.suppressed", + "message.unsubscribed", + "message.opened", + "message.clicked", + "message.inbound", + "message.policy_rejected", + "message.scheduled", + "message.rescheduled", + "message.canceled", + "message.released", + "suppression.added", + "suppression.removed", + "webhook.test", + ] + | str +) + + +# The webhook after a change, with a confirmation message. +WebhookMutationResponse = TypedDict( + "WebhookMutationResponse", + { + "data": Required["WebhookData"], + # Human-readable confirmation + "message": Required[str], + }, +) + + +WebhookScope: TypeAlias = Literal["team", "project", "route"] | str + + +WebhookSecretData = TypedDict( + "WebhookSecretData", + { + "id": Required[str], + "scope": Required["WebhookScope"], + "project_ids": Required[list[str]], + "route_ids": Required[list[str]], + # Use route_ids. + # Deprecated. + "route_id": Required[str | None], + "name": Required[str], + "url": Required[str], + "has_basic_auth": Required[bool], + "events": Required[list[str]], + "enabled": Required[bool], + "include_machine_events": Required[bool], + "delivery_mode_filter": Required["WebhookDeliveryModeFilter"], + "secret": Required[str], + "last_called_at": Required[str | None], + "created_at": Required[str], + "updated_at": Required[str], + }, +) + + +# The webhook with its signing secret, which is only returned when the secret is created or regenerated. +WebhookSecretResponse = TypedDict( + "WebhookSecretResponse", + { + "data": Required["WebhookSecretData"], + # Human-readable confirmation + "message": Required[str], + }, +) diff --git a/src/lettermint/_version.py b/src/lettermint/_version.py new file mode 100644 index 0000000..43c4996 --- /dev/null +++ b/src/lettermint/_version.py @@ -0,0 +1,3 @@ +"""The version of the Lettermint SDK. The release workflow sets it from the git tag.""" + +__version__ = "3.0.0" diff --git a/src/lettermint/client.py b/src/lettermint/client.py deleted file mode 100644 index 5efabfa..0000000 --- a/src/lettermint/client.py +++ /dev/null @@ -1,491 +0,0 @@ -"""HTTP client implementations for the Lettermint SDK.""" - -from __future__ import annotations - -import platform -from importlib.metadata import version -from typing import Any - -import httpx - -from .exceptions import ( - ClientError, - HttpRequestError, - TimeoutError, - ValidationError, -) - -DEFAULT_BASE_URL = "https://api.lettermint.co/v1" -DEFAULT_TIMEOUT = 30.0 - - -class LettermintClient: - """Synchronous HTTP client for the Lettermint API. - - Args: - api_token: API token for authentication. - base_url: Base URL for the API. Defaults to https://api.lettermint.co/v1. - timeout: Request timeout in seconds. Defaults to 30.0. - """ - - def __init__( - self, - api_token: str, - base_url: str | None = None, - timeout: float = DEFAULT_TIMEOUT, - auth_scheme: str = "sending", - ) -> None: - self._api_token = api_token - self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/") - self._timeout = timeout - self._auth_scheme = auth_scheme - self._client = httpx.Client( - base_url=self._base_url, - timeout=self._timeout, - headers={ - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": f"Lettermint/{version('lettermint')} (Python; python {platform.python_version()})", - }, - ) - - def close(self) -> None: - """Close the HTTP client.""" - self._client.close() - - def __enter__(self) -> LettermintClient: - return self - - def __exit__(self, *args: Any) -> None: - self.close() - - def _handle_response(self, response: httpx.Response) -> Any: - """Handle the HTTP response and raise appropriate exceptions.""" - if response.is_success: - if response.status_code == 204: - return None - return response.json() - - try: - response_body = response.json() - except Exception: - response_body = None - - if response.status_code == 422: - error_type = ( - response_body.get("error", "ValidationError") - if isinstance(response_body, dict) - else "ValidationError" - ) - raise ValidationError( - f"Validation error: {error_type}", - error_type, - response_body, - ) - - if response.status_code == 400: - error_message = ( - response_body.get("error", "Unknown client error") - if isinstance(response_body, dict) - else "Unknown client error" - ) - raise ClientError(f"Client error: {error_message}", response_body) - - raise HttpRequestError( - f"HTTP error {response.status_code} {response.reason_phrase}", - response.status_code, - response_body, - ) - - def _request_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]: - safe_headers = { - key: value - for key, value in (headers or {}).items() - if key.lower() not in {"authorization", "x-lettermint-token"} - } - - if self._auth_scheme == "bearer": - return {**safe_headers, "Authorization": f"Bearer {self._api_token}"} - - return {**safe_headers, "x-lettermint-token": self._api_token} - - def get( - self, - path: str, - params: dict[str, str] | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Make a GET request to the API. - - Args: - path: API endpoint path. - params: Query parameters. - headers: Additional request headers. - - Returns: - The parsed JSON response. - - Raises: - HttpRequestError: On HTTP errors. - TimeoutError: On request timeout. - """ - try: - response = self._client.get(path, params=params, headers=self._request_headers(headers)) - return self._handle_response(response) - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - def get_raw( - self, - path: str, - params: dict[str, str] | None = None, - headers: dict[str, str] | None = None, - ) -> str: - """Make a GET request and return the raw response body.""" - try: - response = self._client.get(path, params=params, headers=self._request_headers(headers)) - if response.is_success: - return response.text - self._handle_response(response) - raise AssertionError("unreachable") - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - def post( - self, - path: str, - data: Any | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Make a POST request to the API. - - Args: - path: API endpoint path. - data: Request payload to be JSON-encoded. - headers: Additional request headers. - - Returns: - The parsed JSON response. - - Raises: - HttpRequestError: On HTTP errors. - TimeoutError: On request timeout. - """ - try: - response = self._client.post(path, json=data, headers=self._request_headers(headers)) - return self._handle_response(response) - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - def put( - self, - path: str, - data: Any | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Make a PUT request to the API. - - Args: - path: API endpoint path. - data: Request payload to be JSON-encoded. - headers: Additional request headers. - - Returns: - The parsed JSON response. - - Raises: - HttpRequestError: On HTTP errors. - TimeoutError: On request timeout. - """ - try: - response = self._client.put(path, json=data, headers=self._request_headers(headers)) - return self._handle_response(response) - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - def patch( - self, - path: str, - data: Any | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Make a PATCH request to the API.""" - try: - response = self._client.patch(path, json=data, headers=self._request_headers(headers)) - return self._handle_response(response) - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - def delete( - self, - path: str, - params: dict[str, str] | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Make a DELETE request to the API. - - Args: - path: API endpoint path. - headers: Additional request headers. - - Returns: - The parsed JSON response. - - Raises: - HttpRequestError: On HTTP errors. - TimeoutError: On request timeout. - """ - try: - response = self._client.delete( - path, - params=params, - headers=self._request_headers(headers), - ) - return self._handle_response(response) - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - -class AsyncLettermintClient: - """Asynchronous HTTP client for the Lettermint API. - - Args: - api_token: API token for authentication. - base_url: Base URL for the API. Defaults to https://api.lettermint.co/v1. - timeout: Request timeout in seconds. Defaults to 30.0. - """ - - def __init__( - self, - api_token: str, - base_url: str | None = None, - timeout: float = DEFAULT_TIMEOUT, - auth_scheme: str = "sending", - ) -> None: - self._api_token = api_token - self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/") - self._timeout = timeout - self._auth_scheme = auth_scheme - self._client = httpx.AsyncClient( - base_url=self._base_url, - timeout=self._timeout, - headers={ - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": f"Lettermint/{version('lettermint')} (Python; python {platform.python_version()})", - }, - ) - - async def close(self) -> None: - """Close the HTTP client.""" - await self._client.aclose() - - async def __aenter__(self) -> AsyncLettermintClient: - return self - - async def __aexit__(self, *args: Any) -> None: - await self.close() - - def _handle_response(self, response: httpx.Response) -> Any: - """Handle the HTTP response and raise appropriate exceptions.""" - if response.is_success: - if response.status_code == 204: - return None - return response.json() - - try: - response_body = response.json() - except Exception: - response_body = None - - if response.status_code == 422: - error_type = ( - response_body.get("error", "ValidationError") - if isinstance(response_body, dict) - else "ValidationError" - ) - raise ValidationError( - f"Validation error: {error_type}", - error_type, - response_body, - ) - - if response.status_code == 400: - error_message = ( - response_body.get("error", "Unknown client error") - if isinstance(response_body, dict) - else "Unknown client error" - ) - raise ClientError(f"Client error: {error_message}", response_body) - - raise HttpRequestError( - f"HTTP error {response.status_code} {response.reason_phrase}", - response.status_code, - response_body, - ) - - def _request_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]: - safe_headers = { - key: value - for key, value in (headers or {}).items() - if key.lower() not in {"authorization", "x-lettermint-token"} - } - - if self._auth_scheme == "bearer": - return {**safe_headers, "Authorization": f"Bearer {self._api_token}"} - - return {**safe_headers, "x-lettermint-token": self._api_token} - - async def get( - self, - path: str, - params: dict[str, str] | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Make a GET request to the API. - - Args: - path: API endpoint path. - params: Query parameters. - headers: Additional request headers. - - Returns: - The parsed JSON response. - - Raises: - HttpRequestError: On HTTP errors. - TimeoutError: On request timeout. - """ - try: - response = await self._client.get( - path, - params=params, - headers=self._request_headers(headers), - ) - return self._handle_response(response) - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - async def get_raw( - self, - path: str, - params: dict[str, str] | None = None, - headers: dict[str, str] | None = None, - ) -> str: - """Make a GET request and return the raw response body.""" - try: - response = await self._client.get( - path, - params=params, - headers=self._request_headers(headers), - ) - if response.is_success: - return response.text - self._handle_response(response) - raise AssertionError("unreachable") - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - async def post( - self, - path: str, - data: Any | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Make a POST request to the API. - - Args: - path: API endpoint path. - data: Request payload to be JSON-encoded. - headers: Additional request headers. - - Returns: - The parsed JSON response. - - Raises: - HttpRequestError: On HTTP errors. - TimeoutError: On request timeout. - """ - try: - response = await self._client.post( - path, - json=data, - headers=self._request_headers(headers), - ) - return self._handle_response(response) - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - async def put( - self, - path: str, - data: Any | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Make a PUT request to the API. - - Args: - path: API endpoint path. - data: Request payload to be JSON-encoded. - headers: Additional request headers. - - Returns: - The parsed JSON response. - - Raises: - HttpRequestError: On HTTP errors. - TimeoutError: On request timeout. - """ - try: - response = await self._client.put( - path, - json=data, - headers=self._request_headers(headers), - ) - return self._handle_response(response) - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - async def patch( - self, - path: str, - data: Any | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Make a PATCH request to the API.""" - try: - response = await self._client.patch( - path, json=data, headers=self._request_headers(headers) - ) - return self._handle_response(response) - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e - - async def delete( - self, - path: str, - params: dict[str, str] | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Make a DELETE request to the API. - - Args: - path: API endpoint path. - headers: Additional request headers. - - Returns: - The parsed JSON response. - - Raises: - HttpRequestError: On HTTP errors. - TimeoutError: On request timeout. - """ - try: - response = await self._client.delete( - path, - params=params, - headers=self._request_headers(headers), - ) - return self._handle_response(response) - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timeout after {self._timeout}s") from e diff --git a/src/lettermint/endpoints/__init__.py b/src/lettermint/endpoints/__init__.py deleted file mode 100644 index cabc3e3..0000000 --- a/src/lettermint/endpoints/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Endpoint modules for the Lettermint SDK.""" - -from .email import AsyncEmailEndpoint, EmailEndpoint - -__all__ = [ - "EmailEndpoint", - "AsyncEmailEndpoint", -] diff --git a/src/lettermint/endpoints/api.py b/src/lettermint/endpoints/api.py deleted file mode 100644 index c7e0e74..0000000 --- a/src/lettermint/endpoints/api.py +++ /dev/null @@ -1,812 +0,0 @@ -"""Full API endpoints for the Lettermint SDK.""" - -from __future__ import annotations - -from typing import cast - -from .. import types as lm_types -from .endpoint import AsyncEndpoint, Endpoint - -Query = dict[str, str] - - -class DomainsEndpoint(Endpoint): - def list(self, query: Query | None = None) -> lm_types.DomainIndexResponse: - return cast(lm_types.DomainIndexResponse, self._client.get("/domains", params=query)) - - def create(self, data: lm_types.DomainStoreRequest) -> lm_types.DomainStoreResponse: - return cast(lm_types.DomainStoreResponse, self._client.post("/domains", data=data)) - - def retrieve(self, domain_id: str, query: Query | None = None) -> lm_types.DomainShowResponse: - return cast( - lm_types.DomainShowResponse, - self._client.get(self._path("/domains/{domainId}", domainId=domain_id), params=query), - ) - - def delete(self, domain_id: str) -> lm_types.DomainDestroyResponse: - return cast( - lm_types.DomainDestroyResponse, - self._client.delete(self._path("/domains/{domainId}", domainId=domain_id)), - ) - - def verify_dns_records(self, domain_id: str) -> lm_types.DomainVerifyDnsRecordsResponse: - return cast( - lm_types.DomainVerifyDnsRecordsResponse, - self._client.post( - self._path("/domains/{domainId}/dns-records/verify", domainId=domain_id), - data={}, - ), - ) - - def verify_dns_record( - self, domain_id: str, record_id: str - ) -> lm_types.DomainVerifySpecificDnsRecordResponse: - return cast( - lm_types.DomainVerifySpecificDnsRecordResponse, - self._client.post( - self._path( - "/domains/{domainId}/dns-records/{recordId}/verify", - domainId=domain_id, - recordId=record_id, - ), - data={}, - ), - ) - - def update_projects( - self, domain_id: str, data: lm_types.DomainUpdateProjectsRequest - ) -> lm_types.DomainUpdateProjectsResponse: - return cast( - lm_types.DomainUpdateProjectsResponse, - self._client.put( - self._path("/domains/{domainId}/projects", domainId=domain_id), - data=data, - ), - ) - - -class MessagesEndpoint(Endpoint): - def list(self, query: Query | None = None) -> lm_types.MessageIndexResponse: - return cast(lm_types.MessageIndexResponse, self._client.get("/messages", params=query)) - - def retrieve(self, message_id: str, query: Query | None = None) -> lm_types.MessageShowResponse: - return cast( - lm_types.MessageShowResponse, - self._client.get( - self._path("/messages/{messageId}", messageId=message_id), - params=query, - ), - ) - - def reschedule( - self, message_id: str, data: lm_types.RescheduleMessageRequest - ) -> lm_types.RescheduleMessageResponse: - return cast( - lm_types.RescheduleMessageResponse, - self._client.patch( - self._path("/messages/{messageId}", messageId=message_id), data=data - ), - ) - - def cancel(self, message_id: str) -> lm_types.CancelScheduledMessageResponse: - return cast( - lm_types.CancelScheduledMessageResponse, - self._client.post( - self._path("/messages/{messageId}/cancel", messageId=message_id), data={} - ), - ) - - def process(self, message_id: str) -> lm_types.ProcessInboundMessageResponse: - return cast( - lm_types.ProcessInboundMessageResponse, - self._client.post( - self._path("/messages/{messageId}/process", messageId=message_id), data={} - ), - ) - - def events(self, message_id: str, query: Query | None = None) -> lm_types.MessageEventsResponse: - return cast( - lm_types.MessageEventsResponse, - self._client.get( - self._path("/messages/{messageId}/events", messageId=message_id), - params=query, - ), - ) - - def source(self, message_id: str) -> str: - return self._client.get_raw( - self._path("/messages/{messageId}/source", messageId=message_id) - ) - - def html(self, message_id: str) -> str: - return self._client.get_raw(self._path("/messages/{messageId}/html", messageId=message_id)) - - def text(self, message_id: str) -> str: - return self._client.get_raw(self._path("/messages/{messageId}/text", messageId=message_id)) - - -class ProjectsEndpoint(Endpoint): - def retrieve_report_forwarding(self, project_id: str) -> lm_types.GetReportForwardingResponse: - return cast( - lm_types.GetReportForwardingResponse, - self._client.get( - self._path("/projects/{projectId}/report-forwarding", projectId=project_id) - ), - ) - - def update_report_forwarding( - self, project_id: str, data: lm_types.UpdateReportForwardingRequest - ) -> lm_types.UpdateReportForwardingResponse: - return cast( - lm_types.UpdateReportForwardingResponse, - self._client.put( - self._path("/projects/{projectId}/report-forwarding", projectId=project_id), - data=data, - ), - ) - - def delete_report_forwarding(self, project_id: str) -> None: - self._client.delete( - self._path("/projects/{projectId}/report-forwarding", projectId=project_id) - ) - - def verify_report_forwarding( - self, project_id: str, data: lm_types.VerifyReportForwardingRequest - ) -> lm_types.VerifyReportForwardingResponse: - return cast( - lm_types.VerifyReportForwardingResponse, - self._client.post( - self._path("/projects/{projectId}/report-forwarding/verify", projectId=project_id), - data=data, - ), - ) - - def resend_report_forwarding_code( - self, project_id: str - ) -> lm_types.ResendReportForwardingCodeResponse: - return cast( - lm_types.ResendReportForwardingCodeResponse, - self._client.post( - self._path( - "/projects/{projectId}/report-forwarding/resend-code", projectId=project_id - ) - ), - ) - - def list(self, query: Query | None = None) -> lm_types.ProjectIndexResponse: - return cast(lm_types.ProjectIndexResponse, self._client.get("/projects", params=query)) - - def create(self, data: lm_types.ProjectStoreRequest) -> lm_types.ProjectStoreResponse: - return cast(lm_types.ProjectStoreResponse, self._client.post("/projects", data=data)) - - def retrieve(self, project_id: str, query: Query | None = None) -> lm_types.ProjectShowResponse: - return cast( - lm_types.ProjectShowResponse, - self._client.get( - self._path("/projects/{projectId}", projectId=project_id), - params=query, - ), - ) - - def update( - self, project_id: str, data: lm_types.ProjectUpdateRequest - ) -> lm_types.ProjectUpdateResponse: - return cast( - lm_types.ProjectUpdateResponse, - self._client.put(self._path("/projects/{projectId}", projectId=project_id), data=data), - ) - - def delete(self, project_id: str) -> lm_types.ProjectDestroyResponse: - return cast( - lm_types.ProjectDestroyResponse, - self._client.delete(self._path("/projects/{projectId}", projectId=project_id)), - ) - - def rotate_token(self, project_id: str) -> lm_types.ProjectRotateTokenResponse: - return cast( - lm_types.ProjectRotateTokenResponse, - self._client.post( - self._path("/projects/{projectId}/rotate-token", projectId=project_id), - data={}, - ), - ) - - def routes(self, project_id: str, query: Query | None = None) -> lm_types.RouteIndexResponse: - return cast( - lm_types.RouteIndexResponse, - self._client.get( - self._path("/projects/{projectId}/routes", projectId=project_id), - params=query, - ), - ) - - def create_route( - self, project_id: str, data: lm_types.RouteStoreRequest - ) -> lm_types.RouteStoreResponse: - return cast( - lm_types.RouteStoreResponse, - self._client.post( - self._path("/projects/{projectId}/routes", projectId=project_id), - data=data, - ), - ) - - -class RoutesEndpoint(Endpoint): - def retrieve(self, route_id: str, query: Query | None = None) -> lm_types.RouteShowResponse: - return cast( - lm_types.RouteShowResponse, - self._client.get(self._path("/routes/{routeId}", routeId=route_id), params=query), - ) - - def update( - self, route_id: str, data: lm_types.RouteUpdateRequest - ) -> lm_types.RouteUpdateResponse: - return cast( - lm_types.RouteUpdateResponse, - self._client.put(self._path("/routes/{routeId}", routeId=route_id), data=data), - ) - - def delete(self, route_id: str) -> lm_types.RouteDestroyResponse: - return cast( - lm_types.RouteDestroyResponse, - self._client.delete(self._path("/routes/{routeId}", routeId=route_id)), - ) - - def verify_inbound_domain(self, route_id: str) -> lm_types.RouteVerifyInboundDomainResponse: - return cast( - lm_types.RouteVerifyInboundDomainResponse, - self._client.post( - self._path("/routes/{routeId}/verify-inbound-domain", routeId=route_id), - data={}, - ), - ) - - -class StatsEndpoint(Endpoint): - def retrieve(self, query: Query | None = None) -> lm_types.StatsIndexResponse: - return cast(lm_types.StatsIndexResponse, self._client.get("/stats", params=query)) - - -class SuppressionsEndpoint(Endpoint): - def list(self, query: Query | None = None) -> lm_types.SuppressionIndexResponse: - return cast( - lm_types.SuppressionIndexResponse, self._client.get("/suppressions", params=query) - ) - - def create(self, data: lm_types.SuppressionStoreRequest) -> lm_types.SuppressionStoreResponse: - return cast( - lm_types.SuppressionStoreResponse, self._client.post("/suppressions", data=data) - ) - - def delete(self, suppression_id: str) -> lm_types.SuppressionDestroyResponse: - return cast( - lm_types.SuppressionDestroyResponse, - self._client.delete( - self._path("/suppressions/{suppressionId}", suppressionId=suppression_id) - ), - ) - - -class TeamEndpoint(Endpoint): - def retrieve(self, query: Query | None = None) -> lm_types.TeamShowResponse: - return cast(lm_types.TeamShowResponse, self._client.get("/team", params=query)) - - def update(self, data: lm_types.TeamUpdateRequest) -> lm_types.TeamUpdateResponse: - return cast(lm_types.TeamUpdateResponse, self._client.put("/team", data=data)) - - def usage(self) -> lm_types.TeamUsageResponse: - return cast(lm_types.TeamUsageResponse, self._client.get("/team/usage")) - - def roles(self) -> lm_types.TeamRolesResponse: - return cast(lm_types.TeamRolesResponse, self._client.get("/team/roles")) - - def members(self, query: Query | None = None) -> lm_types.TeamMembersResponse: - return cast(lm_types.TeamMembersResponse, self._client.get("/team/members", params=query)) - - def member(self, user_id: str) -> lm_types.TeamMembersShowResponse: - return cast( - lm_types.TeamMembersShowResponse, - self._client.get(self._path("/team/members/{userId}", userId=user_id)), - ) - - def update_member_assignment( - self, user_id: str, data: lm_types.TeamMembersAssignmentUpdateRequest - ) -> lm_types.TeamMembersAssignmentUpdateResponse: - return cast( - lm_types.TeamMembersAssignmentUpdateResponse, - self._client.put( - self._path("/team/members/{userId}/assignment", userId=user_id), - data=data, - ), - ) - - -class WebhooksEndpoint(Endpoint): - def list(self, query: Query | None = None) -> lm_types.WebhookIndexResponse: - return cast(lm_types.WebhookIndexResponse, self._client.get("/webhooks", params=query)) - - def create(self, data: lm_types.WebhookStoreRequest) -> lm_types.WebhookStoreResponse: - return cast(lm_types.WebhookStoreResponse, self._client.post("/webhooks", data=data)) - - def retrieve(self, webhook_id: str) -> lm_types.WebhookShowResponse: - return cast( - lm_types.WebhookShowResponse, - self._client.get(self._path("/webhooks/{webhookId}", webhookId=webhook_id)), - ) - - def update( - self, webhook_id: str, data: lm_types.WebhookUpdateRequest - ) -> lm_types.WebhookUpdateResponse: - return cast( - lm_types.WebhookUpdateResponse, - self._client.put( - self._path("/webhooks/{webhookId}", webhookId=webhook_id), - data=data, - ), - ) - - def delete(self, webhook_id: str) -> lm_types.WebhookDestroyResponse: - return cast( - lm_types.WebhookDestroyResponse, - self._client.delete(self._path("/webhooks/{webhookId}", webhookId=webhook_id)), - ) - - def test(self, webhook_id: str) -> lm_types.WebhookTestResponse: - return cast( - lm_types.WebhookTestResponse, - self._client.post( - self._path("/webhooks/{webhookId}/test", webhookId=webhook_id), - data={}, - ), - ) - - def regenerate_secret(self, webhook_id: str) -> lm_types.WebhookRegenerateSecretResponse: - return cast( - lm_types.WebhookRegenerateSecretResponse, - self._client.post( - self._path("/webhooks/{webhookId}/regenerate-secret", webhookId=webhook_id), - data={}, - ), - ) - - def deliveries( - self, webhook_id: str, query: Query | None = None - ) -> lm_types.WebhookDeliveriesResponse: - return cast( - lm_types.WebhookDeliveriesResponse, - self._client.get( - self._path("/webhooks/{webhookId}/deliveries", webhookId=webhook_id), - params=query, - ), - ) - - def delivery(self, webhook_id: str, delivery_id: str) -> lm_types.WebhookShowDeliveryResponse: - return cast( - lm_types.WebhookShowDeliveryResponse, - self._client.get( - self._path( - "/webhooks/{webhookId}/deliveries/{deliveryId}", - webhookId=webhook_id, - deliveryId=delivery_id, - ) - ), - ) - - -class AsyncDomainsEndpoint(AsyncEndpoint): - async def list(self, query: Query | None = None) -> lm_types.DomainIndexResponse: - return cast(lm_types.DomainIndexResponse, await self._client.get("/domains", params=query)) - - async def create(self, data: lm_types.DomainStoreRequest) -> lm_types.DomainStoreResponse: - return cast(lm_types.DomainStoreResponse, await self._client.post("/domains", data=data)) - - async def retrieve( - self, domain_id: str, query: Query | None = None - ) -> lm_types.DomainShowResponse: - return cast( - lm_types.DomainShowResponse, - await self._client.get( - self._path("/domains/{domainId}", domainId=domain_id), params=query - ), - ) - - async def reschedule( - self, message_id: str, data: lm_types.RescheduleMessageRequest - ) -> lm_types.RescheduleMessageResponse: - return await AsyncMessagesEndpoint(self._client).reschedule(message_id, data) - - async def cancel(self, message_id: str) -> lm_types.RescheduleMessageResponse: - return await AsyncMessagesEndpoint(self._client).cancel(message_id) - - async def process(self, message_id: str) -> lm_types.ProcessInboundMessageResponse: - return await AsyncMessagesEndpoint(self._client).process(message_id) - - async def delete(self, domain_id: str) -> lm_types.DomainDestroyResponse: - return cast( - lm_types.DomainDestroyResponse, - await self._client.delete(self._path("/domains/{domainId}", domainId=domain_id)), - ) - - async def verify_dns_records(self, domain_id: str) -> lm_types.DomainVerifyDnsRecordsResponse: - return cast( - lm_types.DomainVerifyDnsRecordsResponse, - await self._client.post( - self._path("/domains/{domainId}/dns-records/verify", domainId=domain_id), data={} - ), - ) - - async def verify_dns_record( - self, domain_id: str, record_id: str - ) -> lm_types.DomainVerifySpecificDnsRecordResponse: - return cast( - lm_types.DomainVerifySpecificDnsRecordResponse, - await self._client.post( - self._path( - "/domains/{domainId}/dns-records/{recordId}/verify", - domainId=domain_id, - recordId=record_id, - ), - data={}, - ), - ) - - async def update_projects( - self, domain_id: str, data: lm_types.DomainUpdateProjectsRequest - ) -> lm_types.DomainUpdateProjectsResponse: - return cast( - lm_types.DomainUpdateProjectsResponse, - await self._client.put( - self._path("/domains/{domainId}/projects", domainId=domain_id), data=data - ), - ) - - -class AsyncMessagesEndpoint(AsyncEndpoint): - async def list(self, query: Query | None = None) -> lm_types.MessageIndexResponse: - return cast( - lm_types.MessageIndexResponse, await self._client.get("/messages", params=query) - ) - - async def retrieve( - self, message_id: str, query: Query | None = None - ) -> lm_types.MessageShowResponse: - return cast( - lm_types.MessageShowResponse, - await self._client.get( - self._path("/messages/{messageId}", messageId=message_id), params=query - ), - ) - - async def reschedule( - self, message_id: str, data: lm_types.RescheduleMessageRequest - ) -> lm_types.RescheduleMessageResponse: - return cast( - lm_types.RescheduleMessageResponse, - await self._client.patch( - self._path("/messages/{messageId}", messageId=message_id), data=data - ), - ) - - async def cancel(self, message_id: str) -> lm_types.CancelScheduledMessageResponse: - return cast( - lm_types.CancelScheduledMessageResponse, - await self._client.post( - self._path("/messages/{messageId}/cancel", messageId=message_id), data={} - ), - ) - - async def process(self, message_id: str) -> lm_types.ProcessInboundMessageResponse: - return cast( - lm_types.ProcessInboundMessageResponse, - await self._client.post( - self._path("/messages/{messageId}/process", messageId=message_id), data={} - ), - ) - - async def events( - self, message_id: str, query: Query | None = None - ) -> lm_types.MessageEventsResponse: - return cast( - lm_types.MessageEventsResponse, - await self._client.get( - self._path("/messages/{messageId}/events", messageId=message_id), params=query - ), - ) - - async def source(self, message_id: str) -> str: - return await self._client.get_raw( - self._path("/messages/{messageId}/source", messageId=message_id) - ) - - async def html(self, message_id: str) -> str: - return await self._client.get_raw( - self._path("/messages/{messageId}/html", messageId=message_id) - ) - - async def text(self, message_id: str) -> str: - return await self._client.get_raw( - self._path("/messages/{messageId}/text", messageId=message_id) - ) - - -class AsyncProjectsEndpoint(AsyncEndpoint): - async def retrieve_report_forwarding( - self, project_id: str - ) -> lm_types.GetReportForwardingResponse: - return cast( - lm_types.GetReportForwardingResponse, - await self._client.get( - self._path("/projects/{projectId}/report-forwarding", projectId=project_id) - ), - ) - - async def update_report_forwarding( - self, project_id: str, data: lm_types.UpdateReportForwardingRequest - ) -> lm_types.UpdateReportForwardingResponse: - return cast( - lm_types.UpdateReportForwardingResponse, - await self._client.put( - self._path("/projects/{projectId}/report-forwarding", projectId=project_id), - data=data, - ), - ) - - async def delete_report_forwarding(self, project_id: str) -> None: - await self._client.delete( - self._path("/projects/{projectId}/report-forwarding", projectId=project_id) - ) - - async def verify_report_forwarding( - self, project_id: str, data: lm_types.VerifyReportForwardingRequest - ) -> lm_types.VerifyReportForwardingResponse: - return cast( - lm_types.VerifyReportForwardingResponse, - await self._client.post( - self._path("/projects/{projectId}/report-forwarding/verify", projectId=project_id), - data=data, - ), - ) - - async def resend_report_forwarding_code( - self, project_id: str - ) -> lm_types.ResendReportForwardingCodeResponse: - return cast( - lm_types.ResendReportForwardingCodeResponse, - await self._client.post( - self._path( - "/projects/{projectId}/report-forwarding/resend-code", projectId=project_id - ) - ), - ) - - async def list(self, query: Query | None = None) -> lm_types.ProjectIndexResponse: - return cast( - lm_types.ProjectIndexResponse, await self._client.get("/projects", params=query) - ) - - async def create(self, data: lm_types.ProjectStoreRequest) -> lm_types.ProjectStoreResponse: - return cast(lm_types.ProjectStoreResponse, await self._client.post("/projects", data=data)) - - async def retrieve( - self, project_id: str, query: Query | None = None - ) -> lm_types.ProjectShowResponse: - return cast( - lm_types.ProjectShowResponse, - await self._client.get( - self._path("/projects/{projectId}", projectId=project_id), params=query - ), - ) - - async def update( - self, project_id: str, data: lm_types.ProjectUpdateRequest - ) -> lm_types.ProjectUpdateResponse: - return cast( - lm_types.ProjectUpdateResponse, - await self._client.put( - self._path("/projects/{projectId}", projectId=project_id), data=data - ), - ) - - async def delete(self, project_id: str) -> lm_types.ProjectDestroyResponse: - return cast( - lm_types.ProjectDestroyResponse, - await self._client.delete(self._path("/projects/{projectId}", projectId=project_id)), - ) - - async def rotate_token(self, project_id: str) -> lm_types.ProjectRotateTokenResponse: - return cast( - lm_types.ProjectRotateTokenResponse, - await self._client.post( - self._path("/projects/{projectId}/rotate-token", projectId=project_id), data={} - ), - ) - - async def routes( - self, project_id: str, query: Query | None = None - ) -> lm_types.RouteIndexResponse: - return cast( - lm_types.RouteIndexResponse, - await self._client.get( - self._path("/projects/{projectId}/routes", projectId=project_id), params=query - ), - ) - - async def create_route( - self, project_id: str, data: lm_types.RouteStoreRequest - ) -> lm_types.RouteStoreResponse: - return cast( - lm_types.RouteStoreResponse, - await self._client.post( - self._path("/projects/{projectId}/routes", projectId=project_id), data=data - ), - ) - - -class AsyncRoutesEndpoint(AsyncEndpoint): - async def retrieve( - self, route_id: str, query: Query | None = None - ) -> lm_types.RouteShowResponse: - return cast( - lm_types.RouteShowResponse, - await self._client.get(self._path("/routes/{routeId}", routeId=route_id), params=query), - ) - - async def update( - self, route_id: str, data: lm_types.RouteUpdateRequest - ) -> lm_types.RouteUpdateResponse: - return cast( - lm_types.RouteUpdateResponse, - await self._client.put(self._path("/routes/{routeId}", routeId=route_id), data=data), - ) - - async def delete(self, route_id: str) -> lm_types.RouteDestroyResponse: - return cast( - lm_types.RouteDestroyResponse, - await self._client.delete(self._path("/routes/{routeId}", routeId=route_id)), - ) - - async def verify_inbound_domain( - self, route_id: str - ) -> lm_types.RouteVerifyInboundDomainResponse: - return cast( - lm_types.RouteVerifyInboundDomainResponse, - await self._client.post( - self._path("/routes/{routeId}/verify-inbound-domain", routeId=route_id), data={} - ), - ) - - -class AsyncStatsEndpoint(AsyncEndpoint): - async def retrieve(self, query: Query | None = None) -> lm_types.StatsIndexResponse: - return cast(lm_types.StatsIndexResponse, await self._client.get("/stats", params=query)) - - -class AsyncSuppressionsEndpoint(AsyncEndpoint): - async def list(self, query: Query | None = None) -> lm_types.SuppressionIndexResponse: - return cast( - lm_types.SuppressionIndexResponse, await self._client.get("/suppressions", params=query) - ) - - async def create( - self, data: lm_types.SuppressionStoreRequest - ) -> lm_types.SuppressionStoreResponse: - return cast( - lm_types.SuppressionStoreResponse, await self._client.post("/suppressions", data=data) - ) - - async def delete(self, suppression_id: str) -> lm_types.SuppressionDestroyResponse: - return cast( - lm_types.SuppressionDestroyResponse, - await self._client.delete( - self._path("/suppressions/{suppressionId}", suppressionId=suppression_id) - ), - ) - - -class AsyncTeamEndpoint(AsyncEndpoint): - async def retrieve(self, query: Query | None = None) -> lm_types.TeamShowResponse: - return cast(lm_types.TeamShowResponse, await self._client.get("/team", params=query)) - - async def update(self, data: lm_types.TeamUpdateRequest) -> lm_types.TeamUpdateResponse: - return cast(lm_types.TeamUpdateResponse, await self._client.put("/team", data=data)) - - async def usage(self) -> lm_types.TeamUsageResponse: - return cast(lm_types.TeamUsageResponse, await self._client.get("/team/usage")) - - async def roles(self) -> lm_types.TeamRolesResponse: - return cast(lm_types.TeamRolesResponse, await self._client.get("/team/roles")) - - async def members(self, query: Query | None = None) -> lm_types.TeamMembersResponse: - return cast( - lm_types.TeamMembersResponse, await self._client.get("/team/members", params=query) - ) - - async def member(self, user_id: str) -> lm_types.TeamMembersShowResponse: - return cast( - lm_types.TeamMembersShowResponse, - await self._client.get(self._path("/team/members/{userId}", userId=user_id)), - ) - - async def update_member_assignment( - self, user_id: str, data: lm_types.TeamMembersAssignmentUpdateRequest - ) -> lm_types.TeamMembersAssignmentUpdateResponse: - return cast( - lm_types.TeamMembersAssignmentUpdateResponse, - await self._client.put( - self._path("/team/members/{userId}/assignment", userId=user_id), - data=data, - ), - ) - - -class AsyncWebhooksEndpoint(AsyncEndpoint): - async def list(self, query: Query | None = None) -> lm_types.WebhookIndexResponse: - return cast( - lm_types.WebhookIndexResponse, await self._client.get("/webhooks", params=query) - ) - - async def create(self, data: lm_types.WebhookStoreRequest) -> lm_types.WebhookStoreResponse: - return cast(lm_types.WebhookStoreResponse, await self._client.post("/webhooks", data=data)) - - async def retrieve(self, webhook_id: str) -> lm_types.WebhookShowResponse: - return cast( - lm_types.WebhookShowResponse, - await self._client.get(self._path("/webhooks/{webhookId}", webhookId=webhook_id)), - ) - - async def update( - self, webhook_id: str, data: lm_types.WebhookUpdateRequest - ) -> lm_types.WebhookUpdateResponse: - return cast( - lm_types.WebhookUpdateResponse, - await self._client.put( - self._path("/webhooks/{webhookId}", webhookId=webhook_id), data=data - ), - ) - - async def delete(self, webhook_id: str) -> lm_types.WebhookDestroyResponse: - return cast( - lm_types.WebhookDestroyResponse, - await self._client.delete(self._path("/webhooks/{webhookId}", webhookId=webhook_id)), - ) - - async def test(self, webhook_id: str) -> lm_types.WebhookTestResponse: - return cast( - lm_types.WebhookTestResponse, - await self._client.post( - self._path("/webhooks/{webhookId}/test", webhookId=webhook_id), data={} - ), - ) - - async def regenerate_secret(self, webhook_id: str) -> lm_types.WebhookRegenerateSecretResponse: - return cast( - lm_types.WebhookRegenerateSecretResponse, - await self._client.post( - self._path("/webhooks/{webhookId}/regenerate-secret", webhookId=webhook_id), data={} - ), - ) - - async def deliveries( - self, webhook_id: str, query: Query | None = None - ) -> lm_types.WebhookDeliveriesResponse: - return cast( - lm_types.WebhookDeliveriesResponse, - await self._client.get( - self._path("/webhooks/{webhookId}/deliveries", webhookId=webhook_id), params=query - ), - ) - - async def delivery( - self, webhook_id: str, delivery_id: str - ) -> lm_types.WebhookShowDeliveryResponse: - return cast( - lm_types.WebhookShowDeliveryResponse, - await self._client.get( - self._path( - "/webhooks/{webhookId}/deliveries/{deliveryId}", - webhookId=webhook_id, - deliveryId=delivery_id, - ) - ), - ) diff --git a/src/lettermint/endpoints/email.py b/src/lettermint/endpoints/email.py deleted file mode 100644 index 8a35314..0000000 --- a/src/lettermint/endpoints/email.py +++ /dev/null @@ -1,663 +0,0 @@ -"""Email endpoint for the Lettermint SDK.""" - -from __future__ import annotations - -import sys -from collections.abc import Coroutine -from copy import deepcopy -from typing import TYPE_CHECKING, Any - -if sys.version_info >= (3, 11): - from typing import Self -else: - from typing_extensions import Self - -from ..message_tag import MessageTag, normalize_message_tags -from ..types import ( - SandboxResult, - SendBatchEmailResponse, - SendBatchMailRequest, - SendEmailResponse, - TlsPolicy, -) -from .endpoint import AsyncEndpoint, Endpoint - -if TYPE_CHECKING: - from ..client import AsyncLettermintClient, LettermintClient - - -class EmailEndpoint(Endpoint): - """Synchronous endpoint for sending emails. - - Provides a fluent builder interface for composing and sending emails. - - Example: - >>> client = Lettermint(api_token="your-token") - >>> response = ( - ... client.email - ... .from_("sender@example.com") - ... .to("recipient@example.com") - ... .subject("Hello!") - ... .html("

Welcome!

") - ... .send() - ... ) - >>> print(response["message_id"]) - """ - - def __init__(self, client: LettermintClient) -> None: - super().__init__(client) - self._payload: dict[str, Any] = {} - self._idempotency_key: str | None = None - - def _reset(self) -> None: - """Reset the payload and idempotency key after sending.""" - self._payload = {} - self._idempotency_key = None - - def headers(self, headers: dict[str, str]) -> Self: - """Set custom headers for the email. - - Args: - headers: Dictionary of custom header key-value pairs. - - Returns: - The current instance for method chaining. - - Example: - >>> client.email.headers({"X-Custom-Header": "value"}) - """ - self._payload["headers"] = headers - return self - - def idempotency_key(self, key: str) -> Self: - """Set the idempotency key for the request. - - This helps prevent duplicate email sends when retrying failed requests. - If you provide the same idempotency key for multiple requests, only the - first one will be processed. - - Args: - key: A unique string to identify this request. - - Returns: - The current instance for method chaining. - - Example: - >>> client.email.idempotency_key("unique-id-123") - """ - self._idempotency_key = key - return self - - def from_(self, email: str) -> Self: - """Set the sender email address. - - Supports RFC 5322 addresses, e.g., "John Doe ". - - Note: This method is named `from_` because `from` is a reserved keyword - in Python. - - Args: - email: The sender's email address. - - Returns: - The current instance for method chaining. - - Example: - >>> client.email.from_("John Doe ") - """ - self._payload["from"] = email - return self - - def to(self, *emails: str) -> Self: - """Set one or more recipient email addresses. - - Args: - *emails: One or more recipient email addresses. - - Returns: - The current instance for method chaining. - - Example: - >>> client.email.to("user1@example.com", "user2@example.com") - """ - self._payload["to"] = list(emails) - return self - - def subject(self, subject: str) -> Self: - """Set the subject of the email. - - Args: - subject: The subject line. - - Returns: - The current instance for method chaining. - """ - self._payload["subject"] = subject - return self - - def scheduled_at(self, scheduled_at: str) -> Self: - """Set the requested delivery time for the email.""" - self._payload["scheduled_at"] = scheduled_at - return self - - def html(self, html: str | None) -> Self: - """Set the HTML body of the email. - - Args: - html: The HTML content for the email body. - - Returns: - The current instance for method chaining. - """ - if html is not None: - self._payload["html"] = html - return self - - def text(self, text: str | None) -> Self: - """Set the plain text body of the email. - - Args: - text: The plain text content for the email body. - - Returns: - The current instance for method chaining. - """ - if text is not None: - self._payload["text"] = text - return self - - def cc(self, *emails: str) -> Self: - """Set one or more CC email addresses. - - Args: - *emails: Email addresses to be CC'd. - - Returns: - The current instance for method chaining. - """ - self._payload["cc"] = list(emails) - return self - - def bcc(self, *emails: str) -> Self: - """Set one or more BCC email addresses. - - Args: - *emails: Email addresses to be BCC'd. - - Returns: - The current instance for method chaining. - """ - self._payload["bcc"] = list(emails) - return self - - def reply_to(self, *emails: str) -> Self: - """Set one or more Reply-To email addresses. - - Args: - *emails: Reply-To email addresses. - - Returns: - The current instance for method chaining. - """ - self._payload["reply_to"] = list(emails) - return self - - def route(self, route: str) -> Self: - """Set the routing key for the email. - - Args: - route: The routing key. - - Returns: - The current instance for method chaining. - """ - self._payload["route"] = route - return self - - def attach( - self, - filename: str, - content: str, - content_id: str | None = None, - content_type: str | None = None, - ) -> Self: - """Attach a file to the email. - - Args: - filename: The attachment filename. - content: The base64-encoded file content. - content_id: Optional Content-ID for inline attachments. - content_type: Optional MIME type for the attachment. - - Returns: - The current instance for method chaining. - - Example: - >>> # Regular attachment - >>> client.email.attach("document.pdf", base64_content) - >>> # Inline image - >>> client.email.attach("logo.png", base64_content, "logo@example.com") - """ - if "attachments" not in self._payload: - self._payload["attachments"] = [] - - attachment: dict[str, str] = { - "filename": filename, - "content": content, - } - if content_id is not None: - attachment["content_id"] = content_id - if content_type is not None: - attachment["content_type"] = content_type - - self._payload["attachments"].append(attachment) - return self - - def settings(self, settings: dict[str, bool | TlsPolicy]) -> Self: - """Set per-email settings that override the selected route.""" - self._payload["settings"] = settings - return self - - def metadata(self, metadata: dict[str, str]) -> Self: - """Set metadata for the email. - - Args: - metadata: Dictionary of metadata key-value pairs. - - Returns: - The current instance for method chaining. - - Example: - >>> client.email.metadata({"campaign_id": "123", "user_id": "456"}) - """ - self._payload["metadata"] = metadata - return self - - def tag(self, tag: str) -> Self: - """Set the tag for the email. - - Args: - tag: A string to categorize the email. - - Returns: - The current instance for method chaining. - - Example: - >>> client.email.tag("welcome-campaign") - """ - if len(self._payload.get("tags", [])) >= 20: - raise ValueError("A legacy tag and no more than 19 message tags are permitted") - self._payload["tag"] = tag - return self - - def tags(self, tags: list[MessageTag | dict[str, str]]) -> Self: - """Set reusable name-value tags for the email. - - Dictionaries remain supported for backward compatibility. - """ - maximum = 19 if self._payload.get("tag") is not None else 20 - if len(tags) > maximum: - raise ValueError(f"No more than {maximum} message tags are permitted") - normalized = [tag if isinstance(tag, MessageTag) else MessageTag(**tag) for tag in tags] - names = [tag.name for tag in normalized] - if len(names) != len(set(names)): - raise ValueError("Message tag names must be unique and case-sensitive") - self._payload["tags"] = [tag.to_dict() for tag in normalized] - return self - - def sandbox_result(self, result: SandboxResult) -> Self: - """Select the simulated result for a Sandbox project.""" - self._payload["sandbox_result"] = result - return self - - def send(self) -> SendEmailResponse: - """Send the composed email. - - Returns: - The API response containing the message ID and status. - - Raises: - HttpRequestError: On HTTP errors. - ValidationError: On validation errors (422). - ClientError: On client errors (400). - TimeoutError: On request timeout. - - Example: - >>> response = client.email.from_("sender@example.com").to("recipient@example.com").subject("Hello").send() - >>> print(response["message_id"]) - """ - headers: dict[str, str] | None = None - if self._idempotency_key is not None: - headers = {"Idempotency-Key": self._idempotency_key} - - try: - response: SendEmailResponse = self._client.post( - "/send", - data=self._payload, - headers=headers, - ) - return response - finally: - self._reset() - - def send_batch(self, payload: SendBatchMailRequest) -> SendBatchEmailResponse: - """Send multiple emails in one batch.""" - headers: dict[str, str] | None = None - if self._idempotency_key is not None: - headers = {"Idempotency-Key": self._idempotency_key} - - try: - response: SendBatchEmailResponse = self._client.post( - "/send/batch", - data=normalize_message_tags(payload), - headers=headers, - ) - return response - finally: - self._reset() - - def ping(self) -> str: - """Ping the Sending API.""" - return self._client.get_raw("/ping").strip() - - -class AsyncEmailEndpoint(AsyncEndpoint): - """Asynchronous endpoint for sending emails. - - Provides a fluent builder interface for composing and sending emails. - - Example: - >>> async with AsyncLettermint(api_token="your-token") as client: - ... response = await ( - ... client.email - ... .from_("sender@example.com") - ... .to("recipient@example.com") - ... .subject("Hello!") - ... .html("

Welcome!

") - ... .send() - ... ) - ... print(response["message_id"]) - """ - - def __init__(self, client: AsyncLettermintClient) -> None: - super().__init__(client) - self._payload: dict[str, Any] = {} - self._idempotency_key: str | None = None - - def _reset(self) -> None: - """Reset the payload and idempotency key after sending.""" - self._payload = {} - self._idempotency_key = None - - def headers(self, headers: dict[str, str]) -> Self: - """Set custom headers for the email. - - Args: - headers: Dictionary of custom header key-value pairs. - - Returns: - The current instance for method chaining. - """ - self._payload["headers"] = headers - return self - - def idempotency_key(self, key: str) -> Self: - """Set the idempotency key for the request. - - This helps prevent duplicate email sends when retrying failed requests. - - Args: - key: A unique string to identify this request. - - Returns: - The current instance for method chaining. - """ - self._idempotency_key = key - return self - - def from_(self, email: str) -> Self: - """Set the sender email address. - - Supports RFC 5322 addresses, e.g., "John Doe ". - - Args: - email: The sender's email address. - - Returns: - The current instance for method chaining. - """ - self._payload["from"] = email - return self - - def to(self, *emails: str) -> Self: - """Set one or more recipient email addresses. - - Args: - *emails: One or more recipient email addresses. - - Returns: - The current instance for method chaining. - """ - self._payload["to"] = list(emails) - return self - - def subject(self, subject: str) -> Self: - """Set the subject of the email. - - Args: - subject: The subject line. - - Returns: - The current instance for method chaining. - """ - self._payload["subject"] = subject - return self - - def scheduled_at(self, scheduled_at: str) -> Self: - """Set the requested delivery time for the email.""" - self._payload["scheduled_at"] = scheduled_at - return self - - def html(self, html: str | None) -> Self: - """Set the HTML body of the email. - - Args: - html: The HTML content for the email body. - - Returns: - The current instance for method chaining. - """ - if html is not None: - self._payload["html"] = html - return self - - def text(self, text: str | None) -> Self: - """Set the plain text body of the email. - - Args: - text: The plain text content for the email body. - - Returns: - The current instance for method chaining. - """ - if text is not None: - self._payload["text"] = text - return self - - def cc(self, *emails: str) -> Self: - """Set one or more CC email addresses. - - Args: - *emails: Email addresses to be CC'd. - - Returns: - The current instance for method chaining. - """ - self._payload["cc"] = list(emails) - return self - - def bcc(self, *emails: str) -> Self: - """Set one or more BCC email addresses. - - Args: - *emails: Email addresses to be BCC'd. - - Returns: - The current instance for method chaining. - """ - self._payload["bcc"] = list(emails) - return self - - def reply_to(self, *emails: str) -> Self: - """Set one or more Reply-To email addresses. - - Args: - *emails: Reply-To email addresses. - - Returns: - The current instance for method chaining. - """ - self._payload["reply_to"] = list(emails) - return self - - def route(self, route: str) -> Self: - """Set the routing key for the email. - - Args: - route: The routing key. - - Returns: - The current instance for method chaining. - """ - self._payload["route"] = route - return self - - def attach( - self, - filename: str, - content: str, - content_id: str | None = None, - content_type: str | None = None, - ) -> Self: - """Attach a file to the email. - - Args: - filename: The attachment filename. - content: The base64-encoded file content. - content_id: Optional Content-ID for inline attachments. - content_type: Optional MIME type for the attachment. - - Returns: - The current instance for method chaining. - """ - if "attachments" not in self._payload: - self._payload["attachments"] = [] - - attachment: dict[str, str] = { - "filename": filename, - "content": content, - } - if content_id is not None: - attachment["content_id"] = content_id - if content_type is not None: - attachment["content_type"] = content_type - - self._payload["attachments"].append(attachment) - return self - - def settings(self, settings: dict[str, bool | TlsPolicy]) -> Self: - """Set per-email settings that override the selected route.""" - self._payload["settings"] = settings - return self - - def metadata(self, metadata: dict[str, str]) -> Self: - """Set metadata for the email. - - Args: - metadata: Dictionary of metadata key-value pairs. - - Returns: - The current instance for method chaining. - """ - self._payload["metadata"] = metadata - return self - - def tag(self, tag: str) -> Self: - """Set the tag for the email. - - Args: - tag: A string to categorize the email. - - Returns: - The current instance for method chaining. - """ - if len(self._payload.get("tags", [])) >= 20: - raise ValueError("A legacy tag and no more than 19 message tags are permitted") - self._payload["tag"] = tag - return self - - def tags(self, tags: list[MessageTag | dict[str, str]]) -> Self: - """Set reusable name-value tags for the email. - - Dictionaries remain supported for backward compatibility. - """ - maximum = 19 if self._payload.get("tag") is not None else 20 - if len(tags) > maximum: - raise ValueError(f"No more than {maximum} message tags are permitted") - normalized = [tag if isinstance(tag, MessageTag) else MessageTag(**tag) for tag in tags] - names = [tag.name for tag in normalized] - if len(names) != len(set(names)): - raise ValueError("Message tag names must be unique and case-sensitive") - self._payload["tags"] = [tag.to_dict() for tag in normalized] - return self - - def sandbox_result(self, result: SandboxResult) -> Self: - """Select the simulated result for a Sandbox project.""" - self._payload["sandbox_result"] = result - return self - - def send(self) -> Coroutine[Any, Any, SendEmailResponse]: - """Send the composed email asynchronously. - - Returns: - The API response containing the message ID and status. - - Raises: - HttpRequestError: On HTTP errors. - ValidationError: On validation errors (422). - ClientError: On client errors (400). - TimeoutError: On request timeout. - """ - payload = deepcopy(self._payload) - headers: dict[str, str] | None = None - if self._idempotency_key is not None: - headers = {"Idempotency-Key": self._idempotency_key} - self._reset() - - async def _send() -> SendEmailResponse: - response: SendEmailResponse = await self._client.post( - "/send", - data=payload, - headers=headers, - ) - return response - - return _send() - - async def send_batch(self, payload: SendBatchMailRequest) -> SendBatchEmailResponse: - """Send multiple emails in one batch asynchronously.""" - headers: dict[str, str] | None = None - if self._idempotency_key is not None: - headers = {"Idempotency-Key": self._idempotency_key} - self._reset() - - response: SendBatchEmailResponse = await self._client.post( - "/send/batch", - data=normalize_message_tags(payload), - headers=headers, - ) - return response - - async def ping(self) -> str: - """Ping the Sending API asynchronously.""" - return (await self._client.get_raw("/ping")).strip() diff --git a/src/lettermint/endpoints/endpoint.py b/src/lettermint/endpoints/endpoint.py deleted file mode 100644 index 90a81d4..0000000 --- a/src/lettermint/endpoints/endpoint.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Base endpoint class for the Lettermint SDK.""" - -from __future__ import annotations - -from typing import Any -from urllib.parse import quote - -from ..client import AsyncLettermintClient, LettermintClient - - -class Endpoint: - """Base class for synchronous API endpoints. - - Args: - client: The HTTP client to use for requests. - """ - - def __init__(self, client: LettermintClient) -> None: - self._client = client - - def __enter__(self) -> Endpoint: - return self - - def __exit__(self, *args: Any) -> None: - self._client.close() - - def _path(self, path: str, **parameters: str) -> str: - for key, value in parameters.items(): - path = path.replace(f"{{{key}}}", quote(value, safe="")) - return path - - -class AsyncEndpoint: - """Base class for asynchronous API endpoints. - - Args: - client: The async HTTP client to use for requests. - """ - - def __init__(self, client: AsyncLettermintClient) -> None: - self._client = client - - async def __aenter__(self) -> AsyncEndpoint: - return self - - async def __aexit__(self, *args: Any) -> None: - await self._client.close() - - def _path(self, path: str, **parameters: str) -> str: - for key, value in parameters.items(): - path = path.replace(f"{{{key}}}", quote(value, safe="")) - return path diff --git a/src/lettermint/lettermint.py b/src/lettermint/lettermint.py deleted file mode 100644 index d2ad7ed..0000000 --- a/src/lettermint/lettermint.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Main Lettermint SDK classes.""" - -from __future__ import annotations - -import sys -from typing import Any, cast - -if sys.version_info >= (3, 11): - from typing import Self -else: - from typing_extensions import Self - -from . import types as lm_types -from .client import AsyncLettermintClient, LettermintClient -from .endpoints.api import ( - AsyncDomainsEndpoint, - AsyncMessagesEndpoint, - AsyncProjectsEndpoint, - AsyncRoutesEndpoint, - AsyncStatsEndpoint, - AsyncSuppressionsEndpoint, - AsyncTeamEndpoint, - AsyncWebhooksEndpoint, - DomainsEndpoint, - MessagesEndpoint, - ProjectsEndpoint, - RoutesEndpoint, - StatsEndpoint, - SuppressionsEndpoint, - TeamEndpoint, - WebhooksEndpoint, -) -from .endpoints.email import AsyncEmailEndpoint, EmailEndpoint - - -class _EmailAccessor: - def __get__(self, instance: Lettermint | None, owner: type[Lettermint]) -> Any: - if instance is None: - return owner._email_client - return instance._email_endpoint() - - -class _AsyncEmailAccessor: - def __get__( - self, - instance: AsyncLettermint | None, - owner: type[AsyncLettermint], - ) -> Any: - if instance is None: - return owner._email_client - return instance._email_endpoint() - - -class ApiClient: - def analytics(self, data: lm_types.AnalyticsRequest) -> lm_types.AnalyticsResponse: - return cast(lm_types.AnalyticsResponse, self._client.post("/analytics", data=data)) - - """Synchronous client for the full Lettermint API.""" - - def __init__( - self, - api_token: str, - *, - base_url: str | None = None, - timeout: float = 30.0, - ) -> None: - self._client = LettermintClient( - api_token=api_token, - base_url=base_url, - timeout=timeout, - auth_scheme="bearer", - ) - self.domains = DomainsEndpoint(self._client) - self.messages = MessagesEndpoint(self._client) - self.projects = ProjectsEndpoint(self._client) - self.routes = RoutesEndpoint(self._client) - self.stats = StatsEndpoint(self._client) - self.suppressions = SuppressionsEndpoint(self._client) - self.team = TeamEndpoint(self._client) - self.webhooks = WebhooksEndpoint(self._client) - - def close(self) -> None: - self._client.close() - - def __enter__(self) -> Self: - return self - - def __exit__(self, *args: Any) -> None: - self.close() - - def ping(self) -> str: - return self._client.get_raw("/ping").strip() - - def blocked_file_types(self) -> lm_types.BlockedFileTypesResponse: - return cast( - lm_types.BlockedFileTypesResponse, - self._client.get("/blocked-file-types"), - ) - - -class AsyncApiClient: - async def analytics(self, data: lm_types.AnalyticsRequest) -> lm_types.AnalyticsResponse: - return cast(lm_types.AnalyticsResponse, await self._client.post("/analytics", data=data)) - - """Asynchronous client for the full Lettermint API.""" - - def __init__( - self, - api_token: str, - *, - base_url: str | None = None, - timeout: float = 30.0, - ) -> None: - self._client = AsyncLettermintClient( - api_token=api_token, - base_url=base_url, - timeout=timeout, - auth_scheme="bearer", - ) - self.domains = AsyncDomainsEndpoint(self._client) - self.messages = AsyncMessagesEndpoint(self._client) - self.projects = AsyncProjectsEndpoint(self._client) - self.routes = AsyncRoutesEndpoint(self._client) - self.stats = AsyncStatsEndpoint(self._client) - self.suppressions = AsyncSuppressionsEndpoint(self._client) - self.team = AsyncTeamEndpoint(self._client) - self.webhooks = AsyncWebhooksEndpoint(self._client) - - async def close(self) -> None: - await self._client.close() - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, *args: Any) -> None: - await self.close() - - async def ping(self) -> str: - return (await self._client.get_raw("/ping")).strip() - - async def blocked_file_types(self) -> lm_types.BlockedFileTypesResponse: - return cast( - lm_types.BlockedFileTypesResponse, - await self._client.get("/blocked-file-types"), - ) - - -class Lettermint: - """Synchronous Lettermint SDK client. - - The main entry point for interacting with the Lettermint API. - - Args: - api_token: Your Lettermint API token. - base_url: Custom base URL for the API. Defaults to https://api.lettermint.co/v1. - timeout: Request timeout in seconds. Defaults to 30.0. - - Example: - >>> from lettermint import Lettermint - >>> - >>> client = Lettermint(api_token="your-api-token") - >>> - >>> response = ( - ... client.email - ... .from_("sender@example.com") - ... .to("recipient@example.com") - ... .subject("Hello from Python!") - ... .html("

Welcome!

") - ... .send() - ... ) - >>> - >>> print(response["message_id"]) - - Example with context manager: - >>> with Lettermint(api_token="your-api-token") as client: - ... response = client.email.from_("sender@example.com").to("recipient@example.com").subject("Hello").send() - """ - - email = _EmailAccessor() - - def __init__( - self, - api_token: str, - *, - base_url: str | None = None, - timeout: float = 30.0, - ) -> None: - self._client = LettermintClient( - api_token=api_token, - base_url=base_url, - timeout=timeout, - ) - self._email: EmailEndpoint | None = None - - @classmethod - def _email_client( - cls, - api_token: str, - *, - base_url: str | None = None, - timeout: float = 30.0, - ) -> EmailEndpoint: - client = LettermintClient(api_token=api_token, base_url=base_url, timeout=timeout) - return EmailEndpoint(client) - - @classmethod - def api( - cls, - api_token: str, - *, - base_url: str | None = None, - timeout: float = 30.0, - ) -> ApiClient: - return ApiClient(api_token, base_url=base_url, timeout=timeout) - - def close(self) -> None: - """Close the HTTP client and release resources.""" - self._client.close() - - def __enter__(self) -> Self: - return self - - def __exit__(self, *args: Any) -> None: - self.close() - - def _email_endpoint(self) -> EmailEndpoint: - """Access the email endpoint for sending emails. - - Returns: - The email endpoint instance with fluent builder interface. - - Example: - >>> client.email.from_("sender@example.com").to("recipient@example.com").send() - """ - if self._email is None: - self._email = EmailEndpoint(self._client) - return self._email - - -class AsyncLettermint: - """Asynchronous Lettermint SDK client. - - The main entry point for interacting with the Lettermint API asynchronously. - - Args: - api_token: Your Lettermint API token. - base_url: Custom base URL for the API. Defaults to https://api.lettermint.co/v1. - timeout: Request timeout in seconds. Defaults to 30.0. - - Example: - >>> from lettermint import AsyncLettermint - >>> - >>> async with AsyncLettermint(api_token="your-api-token") as client: - ... response = await ( - ... client.email - ... .from_("sender@example.com") - ... .to("recipient@example.com") - ... .subject("Hello from Python!") - ... .html("

Welcome!

") - ... .send() - ... ) - ... print(response["message_id"]) - """ - - email = _AsyncEmailAccessor() - - def __init__( - self, - api_token: str, - *, - base_url: str | None = None, - timeout: float = 30.0, - ) -> None: - self._client = AsyncLettermintClient( - api_token=api_token, - base_url=base_url, - timeout=timeout, - ) - self._email: AsyncEmailEndpoint | None = None - - @classmethod - def _email_client( - cls, - api_token: str, - *, - base_url: str | None = None, - timeout: float = 30.0, - ) -> AsyncEmailEndpoint: - client = AsyncLettermintClient(api_token=api_token, base_url=base_url, timeout=timeout) - return AsyncEmailEndpoint(client) - - @classmethod - def api( - cls, - api_token: str, - *, - base_url: str | None = None, - timeout: float = 30.0, - ) -> AsyncApiClient: - return AsyncApiClient(api_token, base_url=base_url, timeout=timeout) - - async def close(self) -> None: - """Close the HTTP client and release resources.""" - await self._client.close() - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, *args: Any) -> None: - await self.close() - - def _email_endpoint(self) -> AsyncEmailEndpoint: - """Access the email endpoint for sending emails asynchronously. - - Returns: - The async email endpoint instance with fluent builder interface. - - Example: - >>> await client.email.from_("sender@example.com").to("recipient@example.com").send() - """ - if self._email is None: - self._email = AsyncEmailEndpoint(self._client) - return self._email diff --git a/src/lettermint/message_tag.py b/src/lettermint/message_tag.py deleted file mode 100644 index 59e1035..0000000 --- a/src/lettermint/message_tag.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Typed reusable message tags.""" - -import re -from dataclasses import dataclass -from typing import Any - -_NAME = re.compile(r"^[A-Za-z0-9_-]{1,32}$") -_VALUE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") - - -@dataclass(frozen=True) -class MessageTag: - """A reusable exact-match message tag.""" - - name: str - value: str - - def __post_init__(self) -> None: - if not _NAME.fullmatch(self.name): - raise ValueError("Message tag names must match ^[A-Za-z0-9_-]{1,32}$") - if self.name.lower().startswith("__lettermint"): - raise ValueError("Message tag names must not start with __lettermint") - if not _VALUE.fullmatch(self.value): - raise ValueError("Message tag values must match ^[A-Za-z0-9_-]{1,64}$") - - def to_dict(self) -> dict[str, str]: - """Return the Sending API representation.""" - return {"name": self.name, "value": self.value} - - -def normalize_message_tags(payload: Any) -> Any: - """Normalize and validate typed tags in one message or a batch.""" - if isinstance(payload, list): - return [normalize_message_tags(message) for message in payload] - if not isinstance(payload, dict) or "tags" not in payload: - return payload - - result = dict(payload) - raw_tags = result["tags"] - if not isinstance(raw_tags, list): - raise ValueError("Message tags must be a list") - maximum = 19 if result.get("tag") is not None else 20 - if len(raw_tags) > maximum: - raise ValueError(f"No more than {maximum} message tags are permitted") - tags = [tag if isinstance(tag, MessageTag) else MessageTag(**tag) for tag in raw_tags] - names = [tag.name for tag in tags] - if len(names) != len(set(names)): - raise ValueError("Message tag names must be unique and case-sensitive") - result["tags"] = [tag.to_dict() for tag in tags] - return result diff --git a/src/lettermint/types.py b/src/lettermint/types.py deleted file mode 100644 index 25faa05..0000000 --- a/src/lettermint/types.py +++ /dev/null @@ -1,2746 +0,0 @@ -"""Generated type definitions for the Lettermint SDK.""" - -from __future__ import annotations - -from typing import Any, Literal, Optional, TypedDict - -from typing_extensions import NotRequired, Required, TypeAlias - -MessageStatus: TypeAlias = Literal[ - "scheduled", - "pending", - "queued", - "quarantined", - "suppressed", - "processed", - "delivered", - "opened", - "clicked", - "soft_bounced", - "hard_bounced", - "spam_complaint", - "failed", - "blocked", - "policy_rejected", - "unsubscribed", - "canceled", -] -SandboxResult: TypeAlias = Literal[ - "delivered", - "hard_bounced", - "soft_bounced", - "deferred", - "failed", - "suppressed", - "spam_complaint", - "auto_replied", - "opened", - "clicked", - "unsubscribed", -] -TlsPolicy: TypeAlias = Literal["opportunistic", "enforced"] -SendMailRequest = TypedDict( - "SendMailRequest", - { - "route": "NotRequired[str]", - "from": "Required[str]", - "to": "Required[list[str]]", - "cc": "NotRequired[list[str]]", - "bcc": "NotRequired[list[str]]", - "reply_to": "NotRequired[list[str]]", - "subject": "Required[str]", - "scheduled_at": "NotRequired[str]", - "headers": "NotRequired[dict[str, str]]", - "metadata": "NotRequired[dict[str, str]]", - "tag": "NotRequired[str | None]", - "tags": "NotRequired[list[dict[str, Any]]]", - "settings": "NotRequired[dict[str, Any]]", - "html": "NotRequired[str | None]", - "text": "NotRequired[str | None]", - "attachments": "NotRequired[list[dict[str, Any]]]", - "sandbox_result": "NotRequired[SandboxResult]", - }, -) - -SendBatchMailRequest: TypeAlias = list[SendMailRequest] -AttachmentDelivery: TypeAlias = Literal["inline", "url"] -BuiltInTeamRole: TypeAlias = Literal["owner", "admin", "member"] -CursorPaginator = TypedDict( - "CursorPaginator", - { - "data": "Required[list[str]]", - "path": "Required[str | None]", - "per_page": "Required[int]", - "next_cursor": "Required[str | None]", - "next_page_url": "Required[str | None]", - "prev_cursor": "Required[str | None]", - "prev_page_url": "Required[str | None]", - }, -) -DkimMode: TypeAlias = Literal["legacy_txt", "managed_cname"] -DnsRecordPurpose: TypeAlias = Literal[ - "return_path", "dmarc", "dkim_legacy", "dkim_primary", "dkim_secondary" -] -DnsRecordStatus: TypeAlias = Literal["active", "failed", "pending"] -DnsVerificationScope: TypeAlias = Literal["required", "recommended", "migration", "deprecated"] -RecordType: TypeAlias = Literal["TXT", "CNAME", "MX"] -DomainDnsRecordData = TypedDict( - "DomainDnsRecordData", - { - "id": "Required[str]", - "type": "Required[RecordType]", - "hostname": "Required[str]", - "fqdn": "Required[str]", - "content": "Required[str]", - "status": "Required[DnsRecordStatus]", - "purpose": "Required[DnsRecordPurpose]", - "verification_scope": "Required[DnsVerificationScope]", - "required_for_verification": "Required[bool]", - "verified_at": "Required[str | None]", - "last_checked_at": "Required[str | None]", - }, -) - -DomainData = TypedDict( - "DomainData", - { - "id": "Required[str]", - "domain": "Required[str]", - "dkim_mode": "Required[DkimMode]", - "rotation_ready": "Required[bool]", - "status_changed_at": "Required[str | None]", - "dns_records": "NotRequired[list[DomainDnsRecordData]]", - "projects": "NotRequired[list[dict[str, Any]]]", - "created_at": "Required[str]", - }, -) - -DomainStatus: TypeAlias = Literal[ - "verified", "partially_verified", "pending_verification", "failed_verification" -] -DomainListData = TypedDict( - "DomainListData", - { - "id": "Required[str]", - "domain": "Required[str]", - "status": "Required[DomainStatus]", - "dkim_mode": "Required[DkimMode]", - "status_changed_at": "Required[str | None]", - "created_at": "Required[str]", - }, -) - -InitialRoutes: TypeAlias = Literal["both", "transactional", "broadcast"] -MessageAttachmentData = TypedDict( - "MessageAttachmentData", - { - "size": "Required[int]", - "filename": "Required[str]", - "content_id": "Required[str | None]", - "content_type": "Required[str]", - }, -) - -DeliveryMode: TypeAlias = Literal["live", "sandbox"] -MessageRecipientData = TypedDict( - "MessageRecipientData", - { - "email": "Required[str]", - "name": "Required[str | None]", - "sandbox_result": "Required[SandboxResult | None]", - }, -) - -SpamSymbol = TypedDict( - "SpamSymbol", - { - "name": "Required[str]", - "score": "Required[float]", - "options": "Required[list[str]]", - "description": "Required[str | None]", - }, -) - -MessageType: TypeAlias = Literal["inbound", "outbound"] -MessageData = TypedDict( - "MessageData", - { - "id": "Required[str]", - "type": "Required[MessageType]", - "status": "Required[MessageStatus]", - "status_changed_at": "Required[str | None]", - "scheduled_at": "Required[str | None]", - "tag": "Required[str | None]", - "tags": "Required[list[dict[str, Any]]]", - "from_email": "Required[str]", - "from_name": "Required[str | None]", - "reply_to": "Required[list[str] | None]", - "subject": "Required[str | None]", - "to": "Required[list[MessageRecipientData] | None]", - "cc": "Required[list[MessageRecipientData] | None]", - "bcc": "Required[list[MessageRecipientData] | None]", - "attachments": "Required[list[MessageAttachmentData] | None]", - "metadata": "Required[dict[str, str] | None]", - "spam_score": "NotRequired[float | None]", - "spam_symbols": "NotRequired[list[SpamSymbol]]", - "route_id": "Required[str]", - "created_at": "Required[str]", - "delivery_mode": "Required[DeliveryMode]", - "sandbox_result": "Required[SandboxResult | None]", - }, -) - -MessageEventType: TypeAlias = Literal[ - "scheduled", - "rescheduled", - "canceled", - "released", - "queued", - "processed", - "suppressed", - "delivered", - "auto_replied", - "soft_bounced", - "hard_bounced", - "spam_complaint", - "failed", - "blocked", - "policy_rejected", - "unsubscribed", - "opened", - "clicked", - "inbound_received", - "inbound_queued", - "inbound_spam_blocked", - "inbound_released", - "inbound_processed", - "inbound_retry", -] -MessageEventData = TypedDict( - "MessageEventData", - { - "message_id": "Required[str]", - "event": "Required[MessageEventType]", - "tag": "Required[str | None]", - "tags": "Required[list[dict[str, Any]]]", - "metadata": "Required[dict[str, Any] | None]", - "timestamp": "Required[str]", - }, -) - -MessageListData = TypedDict( - "MessageListData", - { - "id": "Required[str]", - "type": "Required[MessageType]", - "status": "Required[MessageStatus]", - "scheduled_at": "Required[str | None]", - "spam_score": "NotRequired[float | None]", - "from_email": "Required[str]", - "from_name": "Required[str | None]", - "subject": "Required[str | None]", - "to": "Required[list[MessageRecipientData] | None]", - "cc": "Required[list[MessageRecipientData] | None]", - "bcc": "Required[list[MessageRecipientData] | None]", - "reply_to": "Required[list[str] | None]", - "tag": "Required[str | None]", - "tags": "Required[list[dict[str, Any]]]", - "status_changed_at": "Required[str | None]", - "created_at": "Required[str]", - "delivery_mode": "Required[DeliveryMode]", - "sandbox_result": "Required[SandboxResult | None]", - }, -) - -MessageStatsData = TypedDict( - "MessageStatsData", - { - "messages_transactional": "Required[int]", - "messages_broadcast": "Required[int]", - "messages_inbound": "Required[int]", - "deliverability": "Required[float]", - }, -) - -Plan: TypeAlias = Literal["free", "starter", "growth", "pro"] -ProjectAccessScope: TypeAlias = Literal["all", "selected"] -RouteStatisticData = TypedDict( - "RouteStatisticData", - { - "date": "Required[str]", - "sent_count": "Required[int]", - "delivered_count": "Required[int]", - "opened_count": "Required[int]", - "clicked_count": "Required[int]", - "hard_bounce_count": "Required[int]", - "spam_complaint_count": "Required[int]", - "inbound_received_count": "Required[int]", - "observed_opened_count": "NotRequired[int | None]", - "human_opened_count": "NotRequired[int | None]", - "privacy_opened_count": "NotRequired[int | None]", - "effective_opened_count": "Required[int | None]", - "machine_opened_count": "Required[int | None]", - "machine_clicked_count": "Required[int | None]", - }, -) - -RouteType: TypeAlias = Literal["transactional", "broadcast", "inbound"] -RouteData = TypedDict( - "RouteData", - { - "id": "Required[str]", - "project_id": "Required[str]", - "slug": "Required[str]", - "name": "Required[str]", - "route_type": "Required[RouteType]", - "is_default": "Required[bool]", - "inbound_address": "NotRequired[str | None]", - "inbound_mx_hostname": "NotRequired[str]", - "inbound_route_domain": "NotRequired[str | None]", - "inbound_domain": "NotRequired[str | None]", - "inbound_domain_verified_at": "NotRequired[str | None]", - "inbound_spam_threshold": "NotRequired[float | None]", - "attachment_delivery": "NotRequired[AttachmentDelivery]", - "settings": "NotRequired[dict[str, Any] | None]", - "project": "NotRequired[ProjectData]", - "webhooks_count": "NotRequired[int]", - "suppressed_recipients_count": "NotRequired[int]", - "statistics": "NotRequired[list[RouteStatisticData]]", - "created_at": "Required[str]", - "updated_at": "Required[str]", - }, -) - -ProjectData = TypedDict( - "ProjectData", - { - "id": "Required[str]", - "name": "Required[str]", - "smtp_enabled": "Required[bool]", - "redact_email_content": "Required[bool]", - "default_route_id": "Required[str | None]", - "token_generated_at": "Required[str | None]", - "token_last_used_at": "Required[str | None]", - "token_last_used_ip": "Required[str | None]", - "routes": "NotRequired[list[RouteData]]", - "routes_count": "NotRequired[int]", - "domains": "NotRequired[list[DomainData]]", - "domains_count": "NotRequired[int]", - "last_28_days": "NotRequired[MessageStatsData | None]", - "created_at": "Required[str]", - "updated_at": "Required[str]", - "delivery_mode": "Required[DeliveryMode]", - }, -) - -ProjectListData = TypedDict( - "ProjectListData", - { - "id": "Required[str]", - "name": "Required[str]", - "delivery_mode": "Required[DeliveryMode]", - "smtp_enabled": "Required[bool]", - "routes_count": "Required[int]", - "domains_count": "Required[int]", - "last_28_days": "Required[MessageStatsData]", - "created_at": "Required[str]", - "updated_at": "Required[str]", - }, -) - - -RbacConflictCode: TypeAlias = Literal[ - "stale_resource", - "owner_protected", - "last_owner", - "built_in_role_immutable", - "custom_role_requires_pro", -] -RbacPermission: TypeAlias = Literal[ - "team:manage", - "billing:manage", - "security:manage", - "audit:read", - "support:manage", - "members:read", - "members:manage", - "roles:manage", - "team_tokens:read", - "team_tokens:manage", - "team_tokens:rotate", - "team_tokens:revoke", - "projects:create", - "team_suppressions:read", - "team_suppressions:add", - "team_suppressions:remove", - "projects:read", - "projects:manage", - "projects:delete", - "routes:read", - "routes:manage", - "routes:delete", - "domains:read", - "domains:manage", - "domains:delete", - "project_tokens:read", - "project_tokens:manage", - "project_tokens:rotate", - "project_tokens:revoke", - "webhooks:read", - "webhooks:manage", - "webhooks:delete", - "webhooks:rotate_secret", - "stats:read", - "analytics:read", - "messages:read", - "messages:read_content", - "messages:send", - "suppressions:read", - "suppressions:add", - "suppressions:remove", -] -RescheduleMessageRequest = TypedDict( - "RescheduleMessageRequest", - { - "scheduled_at": "Required[str]", - }, -) - -RouteListData = TypedDict( - "RouteListData", - { - "id": "Required[str]", - "slug": "Required[str]", - "name": "Required[str]", - "route_type": "Required[RouteType]", - "is_default": "Required[bool]", - "webhooks_count": "Required[int]", - "suppressed_recipients_count": "Required[int]", - "created_at": "Required[str]", - "updated_at": "Required[str]", - }, -) - -StatsInboundData = TypedDict( - "StatsInboundData", - { - "received": "Required[int]", - }, -) - -StatsTypeData = TypedDict( - "StatsTypeData", - { - "sent": "Required[int]", - "hard_bounced": "Required[int]", - "spam_complaints": "Required[int]", - }, -) - -StatsDailyData = TypedDict( - "StatsDailyData", - { - "date": "Required[str]", - "sent": "Required[int]", - "delivered": "Required[int]", - "hard_bounced": "Required[int]", - "spam_complaints": "Required[int]", - "opened": "Required[int | None]", - "clicked": "Required[int | None]", - "inbound": "Required[StatsInboundData]", - "transactional": "Required[StatsTypeData | None]", - "broadcast": "Required[StatsTypeData | None]", - "observed_opened": "NotRequired[int | None]", - "human_opened": "NotRequired[int | None]", - "privacy_opened": "NotRequired[int | None]", - "effective_opened": "Required[int | None]", - "machine_opened": "Required[int | None]", - "machine_clicked": "Required[int | None]", - }, -) - -StatsTotalsData = TypedDict( - "StatsTotalsData", - { - "sent": "Required[int]", - "delivered": "Required[int]", - "hard_bounced": "Required[int]", - "spam_complaints": "Required[int]", - "opened": "Required[int | None]", - "clicked": "Required[int | None]", - "inbound": "Required[StatsInboundData]", - "transactional": "Required[StatsTypeData | None]", - "broadcast": "Required[StatsTypeData | None]", - "observed_opened": "NotRequired[int | None]", - "human_opened": "NotRequired[int | None]", - "privacy_opened": "NotRequired[int | None]", - "effective_opened": "Required[int | None]", - "machine_opened": "Required[int | None]", - "machine_clicked": "Required[int | None]", - }, -) - -StatsData = TypedDict( - "StatsData", - { - "from": "Required[str]", - "to": "Required[str]", - "totals": "Required[StatsTotalsData]", - "daily": "Required[list[StatsDailyData]]", - }, -) - -StatsRequestData = TypedDict( - "StatsRequestData", - { - "from": "Required[str]", - "to": "Required[str]", - "project_id": "NotRequired[str | None]", - "include_machine": "NotRequired[bool]", - }, -) - -StoreDomainData = TypedDict( - "StoreDomainData", - { - "domain": "Required[str]", - }, -) - -StoreProjectData = TypedDict( - "StoreProjectData", - { - "name": "Required[str]", - "smtp_enabled": "NotRequired[bool]", - "delivery_mode": "NotRequired[DeliveryMode]", - "initial_routes": "NotRequired[InitialRoutes]", - "short_token": "NotRequired[bool]", - "redact_email_content": "NotRequired[bool]", - }, -) - - -StoreRouteData = TypedDict( - "StoreRouteData", - { - "name": "Required[str]", - "route_type": "Required[RouteType]", - "slug": "NotRequired[str | None]", - "settings": "NotRequired[UpdateRouteSettingsData | None]", - "inbound_settings": "NotRequired[UpdateRouteInboundSettingsData | None]", - "inbound_domain": "NotRequired[str | None]", - "inbound_spam_threshold": "NotRequired[float | None]", - "attachment_delivery": "NotRequired[AttachmentDelivery | None]", - }, -) - - -SuppressionReason: TypeAlias = Literal["spam_complaint", "hard_bounce", "unsubscribe", "manual"] -SuppressionScope: TypeAlias = Literal["team", "project", "route"] -SuppressionAppliesTo: TypeAlias = Literal["all", "broadcast"] -StoreSuppressionData = TypedDict( - "StoreSuppressionData", - { - "email": "NotRequired[str | None]", - "emails": "NotRequired[list[str] | None]", - "reason": "Required[SuppressionReason]", - "scope": "Required[SuppressionScope]", - "route_id": "NotRequired[str | None]", - "project_id": "NotRequired[str | None]", - "applies_to": "NotRequired[SuppressionAppliesTo | None]", - }, -) - -WebhookEvent: TypeAlias = Literal[ - "message.created", - "message.sent", - "message.delivered", - "message.auto_replied", - "message.hard_bounced", - "message.soft_bounced", - "message.spam_complaint", - "message.failed", - "message.suppressed", - "message.unsubscribed", - "message.opened", - "message.clicked", - "message.inbound", - "message.policy_rejected", - "message.scheduled", - "message.rescheduled", - "message.canceled", - "message.released", - "suppression.added", - "suppression.removed", - "webhook.test", -] -WebhookScope: TypeAlias = Literal["team", "project", "route"] -WebhookDeliveryModeFilter: TypeAlias = Literal["live", "sandbox", "both"] -WebhookBasicAuthData = TypedDict( - "WebhookBasicAuthData", - { - "username": "Required[str]", - "password": "Required[str]", - }, -) - -StoreWebhookData = TypedDict( - "StoreWebhookData", - { - "name": "Required[str]", - "url": "Required[str]", - "events": "Required[list[WebhookEvent]]", - "enabled": "NotRequired[bool | None]", - "include_machine_events": "NotRequired[bool | None]", - "scope": "NotRequired[WebhookScope | None]", - "project_ids": "NotRequired[list[str]]", - "route_ids": "NotRequired[list[str]]", - "route_id": "NotRequired[str | None]", - "delivery_mode_filter": "NotRequired[WebhookDeliveryModeFilter | None]", - "basic_auth": "NotRequired[Optional[WebhookBasicAuthData]]", # noqa: UP045 - Python 3.9 runtime hint resolution. - }, -) - -SuppressionSourceMessageData = TypedDict( - "SuppressionSourceMessageData", - { - "id": "Required[str]", - "available": "Required[bool]", - "subject": "Required[str | None]", - "created_at": "Required[str | None]", - }, -) - -SuppressionType: TypeAlias = Literal["email", "domain", "extension"] -SuppressedRecipientData = TypedDict( - "SuppressedRecipientData", - { - "id": "Required[str]", - "type": "Required[SuppressionType]", - "value": "Required[str]", - "reason": "Required[SuppressionReason]", - "scope": "Required[SuppressionScope]", - "applies_to": "Required[SuppressionAppliesTo]", - "project_id": "Required[str | None]", - "route_id": "Required[str | None]", - "source_message": "NotRequired[SuppressionSourceMessageData | None]", - "created_at": "Required[str]", - }, -) - -TeamAddonData = TypedDict( - "TeamAddonData", - { - "type": "Required[str | None]", - "expires_at": "Required[str | None]", - }, -) - -TeamType: TypeAlias = Literal["personal", "business"] -TeamData = TypedDict( - "TeamData", - { - "id": "Required[str]", - "name": "Required[str]", - "type": "Required[TeamType]", - "plan": "Required[Plan]", - "included_volume": "Required[int]", - "tier": "Required[int]", - "verified_at": "Required[str | None]", - "features": "NotRequired[list[str]]", - "addons": "NotRequired[list[TeamAddonData]]", - "created_at": "Required[str]", - "domains_count": "NotRequired[int]", - "projects_count": "NotRequired[int]", - "members_count": "NotRequired[int]", - }, -) - -TeamMemberProjectAccessData = TypedDict( - "TeamMemberProjectAccessData", - { - "scope": "Required[ProjectAccessScope]", - "projects": "Required[list[dict[str, Any]]]", - }, -) - -TeamMemberData = TypedDict( - "TeamMemberData", - { - "id": "Required[str]", - "name": "Required[str]", - "email": "Required[str]", - "role": "Required[dict[str, Any]]", - "project_access": "Required[TeamMemberProjectAccessData]", - "joined_at": "Required[str | None]", - }, -) - -TeamRoleData = TypedDict( - "TeamRoleData", - { - "id": "Required[str]", - "name": "Required[str]", - "system_key": "Required[BuiltInTeamRole | None]", - "permissions": "Required[list[RbacPermission]]", - "assignable": "Required[bool]", - }, -) - -TeamUsagePeriodData = TypedDict( - "TeamUsagePeriodData", - { - "usage": "Required[int]", - "last_incremented_at": "Required[str | None]", - "period_start": "Required[str]", - "period_end": "Required[str]", - }, -) - -TeamUsageDetailData = TypedDict( - "TeamUsageDetailData", - { - "current_period": "Required[TeamUsagePeriodData]", - "historical_usage": "Required[list[TeamUsagePeriodData]]", - }, -) - -UpdateDomainProjectsData = TypedDict( - "UpdateDomainProjectsData", - { - "project_ids": "Required[list[str]]", - }, -) - -UpdateProjectData = TypedDict( - "UpdateProjectData", - { - "name": "NotRequired[str | None]", - "smtp_enabled": "NotRequired[bool | None]", - "redact_email_content": "NotRequired[bool | None]", - "default_route_id": "NotRequired[str | None]", - "delivery_mode": "NotRequired[DeliveryMode | None]", - }, -) - -UpdateRouteInboundSettingsData = TypedDict( - "UpdateRouteInboundSettingsData", - { - "inbound_domain": "NotRequired[str | None]", - "inbound_spam_threshold": "NotRequired[float | None]", - "attachment_delivery": "NotRequired[AttachmentDelivery | None]", - }, -) - -UpdateRouteSettingsData = TypedDict( - "UpdateRouteSettingsData", - { - "track_opens": "NotRequired[bool | None]", - "track_clicks": "NotRequired[bool | None]", - "generate_plaintext_fallback": "NotRequired[bool | None]", - "suppress_auto_responders": "NotRequired[bool | None]", - "suppress_disposable_recipients": "NotRequired[bool | None]", - "tls": "NotRequired[TlsPolicy | None]", - "disable_hosted_unsubscribe": "NotRequired[bool | None]", - "redact_email_content": "NotRequired[bool | None]", - }, -) - -UpdateRouteData = TypedDict( - "UpdateRouteData", - { - "name": "NotRequired[str | None]", - "settings": "NotRequired[UpdateRouteSettingsData | None]", - "inbound_settings": "NotRequired[UpdateRouteInboundSettingsData | None]", - "inbound_domain": "NotRequired[str | None]", - "inbound_spam_threshold": "NotRequired[float | None]", - "attachment_delivery": "NotRequired[AttachmentDelivery | None]", - }, -) - - -UpdateTeamData = TypedDict( - "UpdateTeamData", - { - "name": "NotRequired[str]", - }, -) - - -UpdateTeamMemberAssignmentData = TypedDict( - "UpdateTeamMemberAssignmentData", - { - "role_id": "Required[str]", - "project_access": "Required[dict[str, Any]]", - }, -) - -UpdateWebhookData = TypedDict( - "UpdateWebhookData", - { - "name": "NotRequired[str]", - "url": "NotRequired[str]", - "events": "NotRequired[list[WebhookEvent]]", - "enabled": "NotRequired[bool]", - "include_machine_events": "NotRequired[bool]", - "scope": "NotRequired[WebhookScope]", - "project_ids": "NotRequired[list[str]]", - "route_ids": "NotRequired[list[str]]", - "route_id": "NotRequired[str | None]", - "delivery_mode_filter": "NotRequired[WebhookDeliveryModeFilter]", - "basic_auth": "NotRequired[Optional[WebhookBasicAuthData]]", # noqa: UP045 - Python 3.9 runtime hint resolution. - }, -) - -WebhookData = TypedDict( - "WebhookData", - { - "id": "Required[str]", - "scope": "Required[WebhookScope]", - "project_ids": "Required[list[str]]", - "route_ids": "Required[list[str]]", - "route_id": "Required[str | None]", - "name": "Required[str]", - "url": "Required[str]", - "has_basic_auth": "Required[bool]", - "events": "Required[list[str]]", - "enabled": "Required[bool]", - "include_machine_events": "Required[bool]", - "last_called_at": "Required[str | None]", - "created_at": "Required[str]", - "updated_at": "Required[str]", - "delivery_mode_filter": "Required[WebhookDeliveryModeFilter]", - }, -) - -WebhookDeliveryStatus: TypeAlias = Literal[ - "pending", "success", "failed", "client_error", "server_error", "timeout" -] -WebhookDeliveryData = TypedDict( - "WebhookDeliveryData", - { - "id": "Required[str]", - "webhook_id": "Required[str]", - "event_type": "Required[WebhookEvent]", - "source_scope": "Required[str | None]", - "source_project_id": "Required[str | None]", - "source_route_id": "Required[str | None]", - "status": "Required[WebhookDeliveryStatus]", - "attempt_number": "Required[int]", - "http_status_code": "Required[int | None]", - "duration_ms": "Required[int | None]", - "payload": "Required[list[str]]", - "response_body": "Required[str | None]", - "response_headers": "Required[list[str] | None]", - "error_message": "Required[str | None]", - "delivered_at": "Required[str | None]", - "timestamp": "Required[str]", - "sandbox": "Required[bool]", - }, -) - -WebhookDeliveryListData = TypedDict( - "WebhookDeliveryListData", - { - "id": "Required[str]", - "webhook_id": "Required[str]", - "event_type": "Required[WebhookEvent]", - "source_scope": "Required[str | None]", - "source_project_id": "Required[str | None]", - "source_route_id": "Required[str | None]", - "status": "Required[WebhookDeliveryStatus]", - "sandbox": "Required[bool]", - "attempt_number": "Required[int]", - "http_status_code": "Required[int | None]", - "duration_ms": "Required[int | None]", - "delivered_at": "Required[str | None]", - "created_at": "Required[str]", - }, -) - - -WebhookListData = TypedDict( - "WebhookListData", - { - "id": "Required[str]", - "scope": "Required[WebhookScope]", - "project_ids": "Required[list[str]]", - "route_ids": "Required[list[str]]", - "route_id": "Required[str | None]", - "name": "Required[str]", - "url": "Required[str]", - "events": "Required[list[str]]", - "enabled": "Required[bool]", - "delivery_mode_filter": "Required[WebhookDeliveryModeFilter]", - "last_called_at": "Required[str | None]", - "created_at": "Required[str]", - "updated_at": "Required[str]", - "has_basic_auth": "Required[bool]", - }, -) - - -WebhookSecretData = TypedDict( - "WebhookSecretData", - { - "id": "Required[str]", - "scope": "Required[WebhookScope]", - "project_ids": "Required[list[str]]", - "route_ids": "Required[list[str]]", - "route_id": "Required[str | None]", - "name": "Required[str]", - "url": "Required[str]", - "events": "Required[list[str]]", - "enabled": "Required[bool]", - "include_machine_events": "Required[bool]", - "secret": "Required[str]", - "last_called_at": "Required[str | None]", - "created_at": "Required[str]", - "updated_at": "Required[str]", - "delivery_mode_filter": "Required[WebhookDeliveryModeFilter]", - "has_basic_auth": "Required[bool]", - }, -) - -EmailAttachment = TypedDict( - "EmailAttachment", - { - "filename": "Required[str]", - "content": "Required[str]", - "content_type": "NotRequired[str | None]", - "content_id": "NotRequired[str | None]", - }, -) -EmailPayload: TypeAlias = SendMailRequest -EmailStatus: TypeAlias = MessageStatus -SendMailResponse = TypedDict( - "SendMailResponse", - { - "message_id": "Required[str]", - "status": "Required[Literal['pending', 'scheduled']]", - "sandbox": "NotRequired[Literal[True]]", - "sandbox_result": "NotRequired[SandboxResult]", - "scheduled_at": "NotRequired[str]", - }, -) - - -SendBatchMailResponse: TypeAlias = list[SendMailResponse] -PingResponse: TypeAlias = str -DomainIndexResponse = TypedDict( - "DomainIndexResponse", - { - "data": "Required[list[DomainListData]]", - "path": "Required[str | None]", - "per_page": "Required[int]", - "next_cursor": "Required[str | None]", - "next_page_url": "Required[str | None]", - "prev_cursor": "Required[str | None]", - "prev_page_url": "Required[str | None]", - }, -) - -DomainStoreRequest: TypeAlias = StoreDomainData -DomainStoreResponse: TypeAlias = DomainData -DomainShowResponse: TypeAlias = DomainData -DomainDestroyResponse = TypedDict( - "DomainDestroyResponse", - { - "message": "Required[Literal['Domain deleted successfully.']]", - }, -) - -DomainVerifyDnsRecordsResponse = TypedDict( - "DomainVerifyDnsRecordsResponse", - { - "message": "Required[str]", - "recommended_failed_records": "Required[list[dict[str, Any]]]", - }, -) - -DomainVerifySpecificDnsRecordResponse = TypedDict( - "DomainVerifySpecificDnsRecordResponse", - { - "message": "Required[Literal['DNS record verified successfully.']]", - }, -) - -DomainUpdateProjectsRequest: TypeAlias = UpdateDomainProjectsData -DomainUpdateProjectsResponse = TypedDict( - "DomainUpdateProjectsResponse", - { - "data": "Required[DomainData]", - "message": "Required[Literal['Domain projects updated successfully.']]", - }, -) - -BlockedFileTypesResponse = TypedDict( - "BlockedFileTypesResponse", - { - "extensions": "Required[list[str]]", - "mime_types": "Required[list[str]]", - }, -) - -RescheduleMessageResponse = TypedDict( - "RescheduleMessageResponse", - { - "message_id": "Required[str]", - "status": "Required[MessageStatus | None]", - "scheduled_at": "Required[str | None]", - }, -) - -MessageShowResponse: TypeAlias = MessageData -CancelScheduledMessageResponse = TypedDict( - "CancelScheduledMessageResponse", - { - "message_id": "Required[str]", - "status": "Required[MessageStatus | None]", - "scheduled_at": "Required[str | None]", - }, -) - -MessageIndexResponse = TypedDict( - "MessageIndexResponse", - { - "data": "Required[list[MessageListData]]", - "links": "Required[list[str]]", - "meta": "Required[dict[str, Any]]", - }, -) - -MessageEventsResponse = TypedDict( - "MessageEventsResponse", - { - "data": "Required[list[MessageEventData]]", - "links": "Required[list[str]]", - "meta": "Required[dict[str, Any]]", - }, -) - -ProcessInboundMessageResponse = TypedDict( - "ProcessInboundMessageResponse", - { - "data": "Required[dict[str, Any]]", - }, -) - -ProjectIndexResponse = TypedDict( - "ProjectIndexResponse", - { - "data": "Required[list[ProjectListData]]", - "path": "Required[str | None]", - "per_page": "Required[int]", - "next_cursor": "Required[str | None]", - "next_page_url": "Required[str | None]", - "prev_cursor": "Required[str | None]", - "prev_page_url": "Required[str | None]", - }, -) - -ProjectStoreRequest: TypeAlias = StoreProjectData -ProjectStoreResponse = TypedDict( - "ProjectStoreResponse", - { - "data": "Required[ProjectData]", - "message": "Required[str]", - "api_token": "NotRequired[str]", - }, -) - - -ProjectShowResponse: TypeAlias = ProjectData -ProjectUpdateRequest: TypeAlias = UpdateProjectData -ProjectUpdateResponse = TypedDict( - "ProjectUpdateResponse", - { - "data": "Required[ProjectData]", - "message": "Required[Literal['Project updated successfully.']]", - }, -) - -ProjectDestroyResponse = TypedDict( - "ProjectDestroyResponse", - { - "message": "Required[Literal['Project deleted successfully.']]", - }, -) - -ProjectRotateTokenResponse = TypedDict( - "ProjectRotateTokenResponse", - { - "data": "Required[ProjectData]", - "new_token": "Required[str]", - "message": "Required[Literal['API token rotated successfully. Please update your integrations.']]", - }, -) - -RouteIndexResponse = TypedDict( - "RouteIndexResponse", - { - "data": "Required[list[RouteListData]]", - "path": "Required[str | None]", - "per_page": "Required[int]", - "next_cursor": "Required[str | None]", - "next_page_url": "Required[str | None]", - "prev_cursor": "Required[str | None]", - "prev_page_url": "Required[str | None]", - }, -) - -RouteStoreRequest: TypeAlias = StoreRouteData -RouteStoreResponse = TypedDict( - "RouteStoreResponse", - { - "data": "Required[RouteData]", - "message": "Required[Literal['Route created successfully.']]", - }, -) - -RouteShowResponse: TypeAlias = RouteData -RouteUpdateRequest: TypeAlias = UpdateRouteData -RouteUpdateResponse = TypedDict( - "RouteUpdateResponse", - { - "data": "Required[RouteData]", - "message": "Required[Literal['Route updated successfully.']]", - }, -) - -RouteDestroyResponse = TypedDict( - "RouteDestroyResponse", - { - "message": "Required[Literal['Route deleted successfully.']]", - }, -) - -RouteVerifyInboundDomainResponse = TypedDict( - "RouteVerifyInboundDomainResponse", - { - "data": "Required[dict[str, Any]]", - }, -) - - -StatsIndexResponse: TypeAlias = StatsData -SuppressionIndexResponse = TypedDict( - "SuppressionIndexResponse", - { - "data": "Required[list[SuppressedRecipientData]]", - "path": "Required[str]", - "per_page": "Required[int]", - "next_cursor": "Required[str | None]", - "next_page_url": "Required[str | None]", - "prev_cursor": "Required[str | None]", - "prev_page_url": "Required[str | None]", - }, -) - - -SuppressionStoreRequest: TypeAlias = StoreSuppressionData -SuppressionStoreResponse = TypedDict( - "SuppressionStoreResponse", - { - "message": "Required[str | Literal['No emails were added.']]", - "data": "Required[dict[str, Any]]", - }, -) - -SuppressionDestroyResponse = TypedDict( - "SuppressionDestroyResponse", - { - "success": "Required[Literal[True]]", - "status": "Required[Literal['removed', 'review_ticket_created', 'review_ticket_exists']]", - "message": "Required[str]", - "confidence": "NotRequired[float]", - "ticket_identifier": "NotRequired[str]", - }, -) - - -TeamShowResponse: TypeAlias = TeamData -TeamUpdateRequest: TypeAlias = UpdateTeamData -TeamUpdateResponse = TypedDict( - "TeamUpdateResponse", - { - "data": "Required[TeamData]", - "message": "Required[Literal['Team settings updated successfully.']]", - }, -) - -TeamUsageResponse: TypeAlias = TeamUsageDetailData -TeamRolesResponse = TypedDict( - "TeamRolesResponse", - { - "data": "Required[list[TeamRoleData]]", - }, -) - -TeamMembersResponse = TypedDict( - "TeamMembersResponse", - { - "data": "Required[list[TeamMemberData]]", - "path": "Required[str | None]", - "per_page": "Required[int]", - "next_cursor": "Required[str | None]", - "next_page_url": "Required[str | None]", - "prev_cursor": "Required[str | None]", - "prev_page_url": "Required[str | None]", - }, -) - -TeamMembersShowResponse: TypeAlias = TeamMemberData -TeamMembersAssignmentUpdateRequest: TypeAlias = UpdateTeamMemberAssignmentData -TeamMembersAssignmentUpdateResponse: TypeAlias = TeamMemberData -WebhookIndexResponse = TypedDict( - "WebhookIndexResponse", - { - "data": "Required[list[WebhookListData]]", - "path": "Required[str | None]", - "per_page": "Required[int]", - "next_cursor": "Required[str | None]", - "next_page_url": "Required[str | None]", - "prev_cursor": "Required[str | None]", - "prev_page_url": "Required[str | None]", - }, -) - -WebhookStoreRequest: TypeAlias = StoreWebhookData -WebhookStoreResponse = TypedDict( - "WebhookStoreResponse", - { - "data": "Required[WebhookSecretData]", - "message": "Required[Literal['Webhook created successfully. Please save the secret as it will not be shown again.']]", - }, -) - -WebhookShowResponse: TypeAlias = WebhookData -WebhookUpdateRequest: TypeAlias = UpdateWebhookData -WebhookUpdateResponse = TypedDict( - "WebhookUpdateResponse", - { - "data": "Required[WebhookData]", - "message": "Required[Literal['Webhook updated successfully.']]", - }, -) - -WebhookDestroyResponse = TypedDict( - "WebhookDestroyResponse", - { - "message": "Required[Literal['Webhook deleted successfully.']]", - }, -) - -WebhookTestResponse = TypedDict( - "WebhookTestResponse", - { - "message": "Required[Literal['Test webhook dispatched successfully. Check the deliveries endpoint for results.']]", - "delivery_id": "Required[str]", - }, -) - -WebhookRegenerateSecretResponse = TypedDict( - "WebhookRegenerateSecretResponse", - { - "data": "Required[WebhookSecretData]", - "message": "Required[Literal['Webhook secret regenerated successfully. Please update your integration.']]", - }, -) - -WebhookDeliveriesResponse = TypedDict( - "WebhookDeliveriesResponse", - { - "data": "Required[list[WebhookDeliveryListData]]", - "path": "Required[str | None]", - "per_page": "Required[int]", - "next_cursor": "Required[str | None]", - "next_page_url": "Required[str | None]", - "prev_cursor": "Required[str | None]", - "prev_page_url": "Required[str | None]", - }, -) - -WebhookShowDeliveryResponse: TypeAlias = WebhookDeliveryData -SendEmailResponse: TypeAlias = SendMailResponse -SendBatchEmailResponse: TypeAlias = SendBatchMailResponse - -ProjectCreatedData = TypedDict( - "ProjectCreatedData", - { - "data": "Required[ProjectData]", - "message": "Required[str]", - "api_token": "NotRequired[str]", - }, -) - - -ReportForwardingRequest = TypedDict( - "ReportForwardingRequest", - { - "destination": "Required[str]", - }, -) - - -ReportForwardingResource = TypedDict( - "ReportForwardingResource", - { - "destination": "Required[str | None]", - "verified": "Required[bool]", - "verified_at": "Required[str | None]", - }, -) - - -VerifyReportForwardingRequest = TypedDict( - "VerifyReportForwardingRequest", - { - "code": "Required[str]", - }, -) - - -UpdateReportForwardingRequest: TypeAlias = ReportForwardingRequest - -GetReportForwardingResponse = TypedDict( - "GetReportForwardingResponse", - { - "data": "Required[ReportForwardingResource]", - }, -) - - -UpdateReportForwardingResponse = TypedDict( - "UpdateReportForwardingResponse", - { - "data": "Required[ReportForwardingResource]", - }, -) - - -VerifyReportForwardingResponse = TypedDict( - "VerifyReportForwardingResponse", - { - "data": "Required[ReportForwardingResource]", - }, -) - - -ResendReportForwardingCodeResponse = TypedDict( - "ResendReportForwardingCodeResponse", - { - "data": "Required[ReportForwardingResource]", - }, -) - - -AnalyticsResponseData = TypedDict( - "AnalyticsResponseData", - { - "data": "Required[dict[str, Any]]", - "meta": "Required[dict[str, Any]]", - "pagination": "Required[list[str]]", - }, -) - - -AnalyticsRequestFiltersItem = TypedDict( - "AnalyticsRequestFiltersItem", - { - "dimension": "Required[str]", - "operator": "Required[Literal['eq', 'in', 'not_in', 'is_null', 'is_not_null']]", - "values": "NotRequired[list[str]]", - }, -) - - -AnalyticsRequestSort = TypedDict( - "AnalyticsRequestSort", - { - "metric": "Required[Literal['accepted', 'processed', 'suppressed', 'policy_rejected', 'application_failed', 'mta_accepted', 'canceled', 'messages', 'delivered', 'bounced', 'soft_bounced', 'administratively_bounced', 'deferred_recipients', 'deferred_events', 'delivery_attempts', 'attempted_recipients', 'transport_outcome_recipients', 'effective_delivered', 'open_tracked_delivered', 'click_tracked_delivered', 'out_of_band_bounced_recipients', 'out_of_band_bounce_events', 'complained', 'unsubscribed', 'human_opens', 'human_opens_events', 'human_clicks', 'human_clicks_events', 'machine_opens', 'machine_opens_events', 'machine_clicks', 'machine_clicks_events', 'privacy_opens', 'privacy_opens_events', 'privacy_clicks', 'privacy_clicks_events', 'bot_opens', 'bot_opens_events', 'bot_clicks', 'bot_clicks_events', 'scanner_opens', 'scanner_opens_events', 'scanner_clicks', 'scanner_clicks_events', 'observed_opens', 'observed_opens_events', 'observed_clicks', 'observed_clicks_events', 'delivery_rate', 'effective_delivery_rate', 'bounce_rate', 'deferral_rate', 'complaint_rate', 'human_open_rate', 'human_click_rate', 'processing_latency_p50_ms', 'processing_latency_p95_ms', 'processing_latency_p99_ms', 'processing_latency_samples', 'delivery_latency_p50_ms', 'delivery_latency_p95_ms', 'delivery_latency_p99_ms', 'delivery_latency_samples', 'total_latency_p50_ms', 'total_latency_p95_ms', 'total_latency_p99_ms', 'total_latency_samples']]", - "direction": "Required[Literal['asc', 'desc']]", - }, -) - - -AnalyticsRequest = TypedDict( - "AnalyticsRequest", - { - "metrics": "Required[list[Literal['accepted', 'processed', 'suppressed', 'policy_rejected', 'application_failed', 'mta_accepted', 'canceled', 'messages', 'delivered', 'bounced', 'soft_bounced', 'administratively_bounced', 'deferred_recipients', 'deferred_events', 'delivery_attempts', 'attempted_recipients', 'transport_outcome_recipients', 'effective_delivered', 'open_tracked_delivered', 'click_tracked_delivered', 'out_of_band_bounced_recipients', 'out_of_band_bounce_events', 'complained', 'unsubscribed', 'human_opens', 'human_opens_events', 'human_clicks', 'human_clicks_events', 'machine_opens', 'machine_opens_events', 'machine_clicks', 'machine_clicks_events', 'privacy_opens', 'privacy_opens_events', 'privacy_clicks', 'privacy_clicks_events', 'bot_opens', 'bot_opens_events', 'bot_clicks', 'bot_clicks_events', 'scanner_opens', 'scanner_opens_events', 'scanner_clicks', 'scanner_clicks_events', 'observed_opens', 'observed_opens_events', 'observed_clicks', 'observed_clicks_events', 'delivery_rate', 'effective_delivery_rate', 'bounce_rate', 'deferral_rate', 'complaint_rate', 'human_open_rate', 'human_click_rate', 'processing_latency_p50_ms', 'processing_latency_p95_ms', 'processing_latency_p99_ms', 'processing_latency_samples', 'delivery_latency_p50_ms', 'delivery_latency_p95_ms', 'delivery_latency_p99_ms', 'delivery_latency_samples', 'total_latency_p50_ms', 'total_latency_p95_ms', 'total_latency_p99_ms', 'total_latency_samples']]]", - "from": "NotRequired[str]", - "to": "NotRequired[str]", - "timezone": "NotRequired[str]", - "include": "NotRequired[list[Literal['summary', 'time_series', 'breakdown']]]", - "group_by": "NotRequired[list[str]]", - "filters": "NotRequired[list[AnalyticsRequestFiltersItem]]", - "interval": "NotRequired[Literal['hour', 'day']]", - "compare": "NotRequired[Literal['previous_period']]", - "include_trend": "NotRequired[bool]", - "sort": "NotRequired[AnalyticsRequestSort]", - "limit": "NotRequired[int]", - "cursor": "NotRequired[str]", - }, -) - - -AnalyticsResponseMetaComparison = TypedDict( - "AnalyticsResponseMetaComparison", - { - "from": "Required[str]", - "to": "Required[str]", - "partial": "Required[bool]", - }, -) - - -AnalyticsResponseMeta = TypedDict( - "AnalyticsResponseMeta", - { - "time_basis": "NotRequired[Literal['event']]", - "timezone": "NotRequired[str]", - "interval": "NotRequired[Literal['hour', 'day']]", - "from": "NotRequired[str]", - "to": "NotRequired[str]", - "effective_to": "NotRequired[str]", - "alignment": "NotRequired[Literal['hour', 'day']]", - "generated_at": "NotRequired[str]", - "available_since": "NotRequired[str]", - "partial": "NotRequired[bool]", - "ongoing": "NotRequired[bool]", - "collection_completeness": "NotRequired[Literal['best_effort']]", - "last_ingested_at": "NotRequired[str | None]", - "metric_definition_version": "NotRequired[str]", - "ranked_group_limit": "NotRequired[int]", - "comparison": "NotRequired[AnalyticsResponseMetaComparison]", - }, -) - - -AnalyticsResponsePagination = TypedDict( - "AnalyticsResponsePagination", - { - "total_groups": "Required[int]", - "returned_groups": "Required[int]", - "next_cursor": "Required[str | None]", - "truncated": "Required[bool]", - }, -) - - -AnalyticsResponse = TypedDict( - "AnalyticsResponse", - { - "data": "Required[AnalyticsResponsePayload]", - "meta": "Required[AnalyticsResponseMeta]", - "pagination": "Required[AnalyticsResponsePagination]", - }, -) - - -AnalyticsResponsePayloadSummaryMetrics = TypedDict( - "AnalyticsResponsePayloadSummaryMetrics", - { - "accepted": "NotRequired[int | None]", - "processed": "NotRequired[int | None]", - "suppressed": "NotRequired[int | None]", - "policy_rejected": "NotRequired[int | None]", - "application_failed": "NotRequired[int | None]", - "mta_accepted": "NotRequired[int | None]", - "canceled": "NotRequired[int | None]", - "messages": "NotRequired[int | None]", - "delivered": "NotRequired[int | None]", - "bounced": "NotRequired[int | None]", - "soft_bounced": "NotRequired[int | None]", - "administratively_bounced": "NotRequired[int | None]", - "deferred_recipients": "NotRequired[int | None]", - "deferred_events": "NotRequired[int | None]", - "delivery_attempts": "NotRequired[int | None]", - "attempted_recipients": "NotRequired[int | None]", - "transport_outcome_recipients": "NotRequired[int | None]", - "effective_delivered": "NotRequired[int | None]", - "open_tracked_delivered": "NotRequired[int | None]", - "click_tracked_delivered": "NotRequired[int | None]", - "out_of_band_bounced_recipients": "NotRequired[int | None]", - "out_of_band_bounce_events": "NotRequired[int | None]", - "complained": "NotRequired[int | None]", - "unsubscribed": "NotRequired[int | None]", - "human_opens": "NotRequired[int | None]", - "human_opens_events": "NotRequired[int | None]", - "human_clicks": "NotRequired[int | None]", - "human_clicks_events": "NotRequired[int | None]", - "machine_opens": "NotRequired[int | None]", - "machine_opens_events": "NotRequired[int | None]", - "machine_clicks": "NotRequired[int | None]", - "machine_clicks_events": "NotRequired[int | None]", - "privacy_opens": "NotRequired[int | None]", - "privacy_opens_events": "NotRequired[int | None]", - "privacy_clicks": "NotRequired[int | None]", - "privacy_clicks_events": "NotRequired[int | None]", - "bot_opens": "NotRequired[int | None]", - "bot_opens_events": "NotRequired[int | None]", - "bot_clicks": "NotRequired[int | None]", - "bot_clicks_events": "NotRequired[int | None]", - "scanner_opens": "NotRequired[int | None]", - "scanner_opens_events": "NotRequired[int | None]", - "scanner_clicks": "NotRequired[int | None]", - "scanner_clicks_events": "NotRequired[int | None]", - "observed_opens": "NotRequired[int | None]", - "observed_opens_events": "NotRequired[int | None]", - "observed_clicks": "NotRequired[int | None]", - "observed_clicks_events": "NotRequired[int | None]", - "delivery_rate": "NotRequired[float | None]", - "effective_delivery_rate": "NotRequired[float | None]", - "bounce_rate": "NotRequired[float | None]", - "deferral_rate": "NotRequired[float | None]", - "complaint_rate": "NotRequired[float | None]", - "human_open_rate": "NotRequired[float | None]", - "human_click_rate": "NotRequired[float | None]", - "processing_latency_p50_ms": "NotRequired[float | None]", - "processing_latency_p95_ms": "NotRequired[float | None]", - "processing_latency_p99_ms": "NotRequired[float | None]", - "processing_latency_samples": "NotRequired[int | None]", - "delivery_latency_p50_ms": "NotRequired[float | None]", - "delivery_latency_p95_ms": "NotRequired[float | None]", - "delivery_latency_p99_ms": "NotRequired[float | None]", - "delivery_latency_samples": "NotRequired[int | None]", - "total_latency_p50_ms": "NotRequired[float | None]", - "total_latency_p95_ms": "NotRequired[float | None]", - "total_latency_p99_ms": "NotRequired[float | None]", - "total_latency_samples": "NotRequired[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryRateBasesDeliveryRate = TypedDict( - "AnalyticsResponsePayloadSummaryRateBasesDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryRateBasesEffectiveDeliveryRate = TypedDict( - "AnalyticsResponsePayloadSummaryRateBasesEffectiveDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryRateBasesBounceRate = TypedDict( - "AnalyticsResponsePayloadSummaryRateBasesBounceRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryRateBasesDeferralRate = TypedDict( - "AnalyticsResponsePayloadSummaryRateBasesDeferralRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryRateBasesComplaintRate = TypedDict( - "AnalyticsResponsePayloadSummaryRateBasesComplaintRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryRateBasesHumanOpenRate = TypedDict( - "AnalyticsResponsePayloadSummaryRateBasesHumanOpenRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryRateBasesHumanClickRate = TypedDict( - "AnalyticsResponsePayloadSummaryRateBasesHumanClickRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryRateBases = TypedDict( - "AnalyticsResponsePayloadSummaryRateBases", - { - "delivery_rate": "NotRequired[AnalyticsResponsePayloadSummaryRateBasesDeliveryRate]", - "effective_delivery_rate": "NotRequired[AnalyticsResponsePayloadSummaryRateBasesEffectiveDeliveryRate]", - "bounce_rate": "NotRequired[AnalyticsResponsePayloadSummaryRateBasesBounceRate]", - "deferral_rate": "NotRequired[AnalyticsResponsePayloadSummaryRateBasesDeferralRate]", - "complaint_rate": "NotRequired[AnalyticsResponsePayloadSummaryRateBasesComplaintRate]", - "human_open_rate": "NotRequired[AnalyticsResponsePayloadSummaryRateBasesHumanOpenRate]", - "human_click_rate": "NotRequired[AnalyticsResponsePayloadSummaryRateBasesHumanClickRate]", - }, -) - - -AnalyticsResponsePayloadSummaryPreviousMetrics = TypedDict( - "AnalyticsResponsePayloadSummaryPreviousMetrics", - { - "accepted": "NotRequired[int | None]", - "processed": "NotRequired[int | None]", - "suppressed": "NotRequired[int | None]", - "policy_rejected": "NotRequired[int | None]", - "application_failed": "NotRequired[int | None]", - "mta_accepted": "NotRequired[int | None]", - "canceled": "NotRequired[int | None]", - "messages": "NotRequired[int | None]", - "delivered": "NotRequired[int | None]", - "bounced": "NotRequired[int | None]", - "soft_bounced": "NotRequired[int | None]", - "administratively_bounced": "NotRequired[int | None]", - "deferred_recipients": "NotRequired[int | None]", - "deferred_events": "NotRequired[int | None]", - "delivery_attempts": "NotRequired[int | None]", - "attempted_recipients": "NotRequired[int | None]", - "transport_outcome_recipients": "NotRequired[int | None]", - "effective_delivered": "NotRequired[int | None]", - "open_tracked_delivered": "NotRequired[int | None]", - "click_tracked_delivered": "NotRequired[int | None]", - "out_of_band_bounced_recipients": "NotRequired[int | None]", - "out_of_band_bounce_events": "NotRequired[int | None]", - "complained": "NotRequired[int | None]", - "unsubscribed": "NotRequired[int | None]", - "human_opens": "NotRequired[int | None]", - "human_opens_events": "NotRequired[int | None]", - "human_clicks": "NotRequired[int | None]", - "human_clicks_events": "NotRequired[int | None]", - "machine_opens": "NotRequired[int | None]", - "machine_opens_events": "NotRequired[int | None]", - "machine_clicks": "NotRequired[int | None]", - "machine_clicks_events": "NotRequired[int | None]", - "privacy_opens": "NotRequired[int | None]", - "privacy_opens_events": "NotRequired[int | None]", - "privacy_clicks": "NotRequired[int | None]", - "privacy_clicks_events": "NotRequired[int | None]", - "bot_opens": "NotRequired[int | None]", - "bot_opens_events": "NotRequired[int | None]", - "bot_clicks": "NotRequired[int | None]", - "bot_clicks_events": "NotRequired[int | None]", - "scanner_opens": "NotRequired[int | None]", - "scanner_opens_events": "NotRequired[int | None]", - "scanner_clicks": "NotRequired[int | None]", - "scanner_clicks_events": "NotRequired[int | None]", - "observed_opens": "NotRequired[int | None]", - "observed_opens_events": "NotRequired[int | None]", - "observed_clicks": "NotRequired[int | None]", - "observed_clicks_events": "NotRequired[int | None]", - "delivery_rate": "NotRequired[float | None]", - "effective_delivery_rate": "NotRequired[float | None]", - "bounce_rate": "NotRequired[float | None]", - "deferral_rate": "NotRequired[float | None]", - "complaint_rate": "NotRequired[float | None]", - "human_open_rate": "NotRequired[float | None]", - "human_click_rate": "NotRequired[float | None]", - "processing_latency_p50_ms": "NotRequired[float | None]", - "processing_latency_p95_ms": "NotRequired[float | None]", - "processing_latency_p99_ms": "NotRequired[float | None]", - "processing_latency_samples": "NotRequired[int | None]", - "delivery_latency_p50_ms": "NotRequired[float | None]", - "delivery_latency_p95_ms": "NotRequired[float | None]", - "delivery_latency_p99_ms": "NotRequired[float | None]", - "delivery_latency_samples": "NotRequired[int | None]", - "total_latency_p50_ms": "NotRequired[float | None]", - "total_latency_p95_ms": "NotRequired[float | None]", - "total_latency_p99_ms": "NotRequired[float | None]", - "total_latency_samples": "NotRequired[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryPreviousRateBasesDeliveryRate = TypedDict( - "AnalyticsResponsePayloadSummaryPreviousRateBasesDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryPreviousRateBasesEffectiveDeliveryRate = TypedDict( - "AnalyticsResponsePayloadSummaryPreviousRateBasesEffectiveDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryPreviousRateBasesBounceRate = TypedDict( - "AnalyticsResponsePayloadSummaryPreviousRateBasesBounceRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryPreviousRateBasesDeferralRate = TypedDict( - "AnalyticsResponsePayloadSummaryPreviousRateBasesDeferralRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryPreviousRateBasesComplaintRate = TypedDict( - "AnalyticsResponsePayloadSummaryPreviousRateBasesComplaintRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryPreviousRateBasesHumanOpenRate = TypedDict( - "AnalyticsResponsePayloadSummaryPreviousRateBasesHumanOpenRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryPreviousRateBasesHumanClickRate = TypedDict( - "AnalyticsResponsePayloadSummaryPreviousRateBasesHumanClickRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadSummaryPreviousRateBases = TypedDict( - "AnalyticsResponsePayloadSummaryPreviousRateBases", - { - "delivery_rate": "NotRequired[AnalyticsResponsePayloadSummaryPreviousRateBasesDeliveryRate]", - "effective_delivery_rate": "NotRequired[AnalyticsResponsePayloadSummaryPreviousRateBasesEffectiveDeliveryRate]", - "bounce_rate": "NotRequired[AnalyticsResponsePayloadSummaryPreviousRateBasesBounceRate]", - "deferral_rate": "NotRequired[AnalyticsResponsePayloadSummaryPreviousRateBasesDeferralRate]", - "complaint_rate": "NotRequired[AnalyticsResponsePayloadSummaryPreviousRateBasesComplaintRate]", - "human_open_rate": "NotRequired[AnalyticsResponsePayloadSummaryPreviousRateBasesHumanOpenRate]", - "human_click_rate": "NotRequired[AnalyticsResponsePayloadSummaryPreviousRateBasesHumanClickRate]", - }, -) - - -AnalyticsResponsePayloadSummaryPrevious = TypedDict( - "AnalyticsResponsePayloadSummaryPrevious", - { - "metrics": "Required[AnalyticsResponsePayloadSummaryPreviousMetrics]", - "rate_bases": "Required[AnalyticsResponsePayloadSummaryPreviousRateBases]", - }, -) - - -AnalyticsResponsePayloadSummary = TypedDict( - "AnalyticsResponsePayloadSummary", - { - "metrics": "NotRequired[AnalyticsResponsePayloadSummaryMetrics]", - "rate_bases": "NotRequired[AnalyticsResponsePayloadSummaryRateBases]", - "previous": "NotRequired[AnalyticsResponsePayloadSummaryPrevious]", - "change": "NotRequired[dict[str, dict[str, Any]]]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemMetrics = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemMetrics", - { - "accepted": "NotRequired[int | None]", - "processed": "NotRequired[int | None]", - "suppressed": "NotRequired[int | None]", - "policy_rejected": "NotRequired[int | None]", - "application_failed": "NotRequired[int | None]", - "mta_accepted": "NotRequired[int | None]", - "canceled": "NotRequired[int | None]", - "messages": "NotRequired[int | None]", - "delivered": "NotRequired[int | None]", - "bounced": "NotRequired[int | None]", - "soft_bounced": "NotRequired[int | None]", - "administratively_bounced": "NotRequired[int | None]", - "deferred_recipients": "NotRequired[int | None]", - "deferred_events": "NotRequired[int | None]", - "delivery_attempts": "NotRequired[int | None]", - "attempted_recipients": "NotRequired[int | None]", - "transport_outcome_recipients": "NotRequired[int | None]", - "effective_delivered": "NotRequired[int | None]", - "open_tracked_delivered": "NotRequired[int | None]", - "click_tracked_delivered": "NotRequired[int | None]", - "out_of_band_bounced_recipients": "NotRequired[int | None]", - "out_of_band_bounce_events": "NotRequired[int | None]", - "complained": "NotRequired[int | None]", - "unsubscribed": "NotRequired[int | None]", - "human_opens": "NotRequired[int | None]", - "human_opens_events": "NotRequired[int | None]", - "human_clicks": "NotRequired[int | None]", - "human_clicks_events": "NotRequired[int | None]", - "machine_opens": "NotRequired[int | None]", - "machine_opens_events": "NotRequired[int | None]", - "machine_clicks": "NotRequired[int | None]", - "machine_clicks_events": "NotRequired[int | None]", - "privacy_opens": "NotRequired[int | None]", - "privacy_opens_events": "NotRequired[int | None]", - "privacy_clicks": "NotRequired[int | None]", - "privacy_clicks_events": "NotRequired[int | None]", - "bot_opens": "NotRequired[int | None]", - "bot_opens_events": "NotRequired[int | None]", - "bot_clicks": "NotRequired[int | None]", - "bot_clicks_events": "NotRequired[int | None]", - "scanner_opens": "NotRequired[int | None]", - "scanner_opens_events": "NotRequired[int | None]", - "scanner_clicks": "NotRequired[int | None]", - "scanner_clicks_events": "NotRequired[int | None]", - "observed_opens": "NotRequired[int | None]", - "observed_opens_events": "NotRequired[int | None]", - "observed_clicks": "NotRequired[int | None]", - "observed_clicks_events": "NotRequired[int | None]", - "delivery_rate": "NotRequired[float | None]", - "effective_delivery_rate": "NotRequired[float | None]", - "bounce_rate": "NotRequired[float | None]", - "deferral_rate": "NotRequired[float | None]", - "complaint_rate": "NotRequired[float | None]", - "human_open_rate": "NotRequired[float | None]", - "human_click_rate": "NotRequired[float | None]", - "processing_latency_p50_ms": "NotRequired[float | None]", - "processing_latency_p95_ms": "NotRequired[float | None]", - "processing_latency_p99_ms": "NotRequired[float | None]", - "processing_latency_samples": "NotRequired[int | None]", - "delivery_latency_p50_ms": "NotRequired[float | None]", - "delivery_latency_p95_ms": "NotRequired[float | None]", - "delivery_latency_p99_ms": "NotRequired[float | None]", - "delivery_latency_samples": "NotRequired[int | None]", - "total_latency_p50_ms": "NotRequired[float | None]", - "total_latency_p95_ms": "NotRequired[float | None]", - "total_latency_p99_ms": "NotRequired[float | None]", - "total_latency_samples": "NotRequired[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemRateBasesDeliveryRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemRateBasesDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemRateBasesEffectiveDeliveryRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemRateBasesEffectiveDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemRateBasesBounceRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemRateBasesBounceRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemRateBasesDeferralRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemRateBasesDeferralRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemRateBasesComplaintRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemRateBasesComplaintRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemRateBasesHumanOpenRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemRateBasesHumanOpenRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemRateBasesHumanClickRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemRateBasesHumanClickRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemRateBases = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemRateBases", - { - "delivery_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemRateBasesDeliveryRate]", - "effective_delivery_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemRateBasesEffectiveDeliveryRate]", - "bounce_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemRateBasesBounceRate]", - "deferral_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemRateBasesDeferralRate]", - "complaint_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemRateBasesComplaintRate]", - "human_open_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemRateBasesHumanOpenRate]", - "human_click_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemRateBasesHumanClickRate]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemPreviousMetrics = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemPreviousMetrics", - { - "accepted": "NotRequired[int | None]", - "processed": "NotRequired[int | None]", - "suppressed": "NotRequired[int | None]", - "policy_rejected": "NotRequired[int | None]", - "application_failed": "NotRequired[int | None]", - "mta_accepted": "NotRequired[int | None]", - "canceled": "NotRequired[int | None]", - "messages": "NotRequired[int | None]", - "delivered": "NotRequired[int | None]", - "bounced": "NotRequired[int | None]", - "soft_bounced": "NotRequired[int | None]", - "administratively_bounced": "NotRequired[int | None]", - "deferred_recipients": "NotRequired[int | None]", - "deferred_events": "NotRequired[int | None]", - "delivery_attempts": "NotRequired[int | None]", - "attempted_recipients": "NotRequired[int | None]", - "transport_outcome_recipients": "NotRequired[int | None]", - "effective_delivered": "NotRequired[int | None]", - "open_tracked_delivered": "NotRequired[int | None]", - "click_tracked_delivered": "NotRequired[int | None]", - "out_of_band_bounced_recipients": "NotRequired[int | None]", - "out_of_band_bounce_events": "NotRequired[int | None]", - "complained": "NotRequired[int | None]", - "unsubscribed": "NotRequired[int | None]", - "human_opens": "NotRequired[int | None]", - "human_opens_events": "NotRequired[int | None]", - "human_clicks": "NotRequired[int | None]", - "human_clicks_events": "NotRequired[int | None]", - "machine_opens": "NotRequired[int | None]", - "machine_opens_events": "NotRequired[int | None]", - "machine_clicks": "NotRequired[int | None]", - "machine_clicks_events": "NotRequired[int | None]", - "privacy_opens": "NotRequired[int | None]", - "privacy_opens_events": "NotRequired[int | None]", - "privacy_clicks": "NotRequired[int | None]", - "privacy_clicks_events": "NotRequired[int | None]", - "bot_opens": "NotRequired[int | None]", - "bot_opens_events": "NotRequired[int | None]", - "bot_clicks": "NotRequired[int | None]", - "bot_clicks_events": "NotRequired[int | None]", - "scanner_opens": "NotRequired[int | None]", - "scanner_opens_events": "NotRequired[int | None]", - "scanner_clicks": "NotRequired[int | None]", - "scanner_clicks_events": "NotRequired[int | None]", - "observed_opens": "NotRequired[int | None]", - "observed_opens_events": "NotRequired[int | None]", - "observed_clicks": "NotRequired[int | None]", - "observed_clicks_events": "NotRequired[int | None]", - "delivery_rate": "NotRequired[float | None]", - "effective_delivery_rate": "NotRequired[float | None]", - "bounce_rate": "NotRequired[float | None]", - "deferral_rate": "NotRequired[float | None]", - "complaint_rate": "NotRequired[float | None]", - "human_open_rate": "NotRequired[float | None]", - "human_click_rate": "NotRequired[float | None]", - "processing_latency_p50_ms": "NotRequired[float | None]", - "processing_latency_p95_ms": "NotRequired[float | None]", - "processing_latency_p99_ms": "NotRequired[float | None]", - "processing_latency_samples": "NotRequired[int | None]", - "delivery_latency_p50_ms": "NotRequired[float | None]", - "delivery_latency_p95_ms": "NotRequired[float | None]", - "delivery_latency_p99_ms": "NotRequired[float | None]", - "delivery_latency_samples": "NotRequired[int | None]", - "total_latency_p50_ms": "NotRequired[float | None]", - "total_latency_p95_ms": "NotRequired[float | None]", - "total_latency_p99_ms": "NotRequired[float | None]", - "total_latency_samples": "NotRequired[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesDeliveryRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesEffectiveDeliveryRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesEffectiveDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesBounceRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesBounceRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesDeferralRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesDeferralRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesComplaintRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesComplaintRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesHumanOpenRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesHumanOpenRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesHumanClickRate = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesHumanClickRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemPreviousRateBases = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemPreviousRateBases", - { - "delivery_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesDeliveryRate]", - "effective_delivery_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesEffectiveDeliveryRate]", - "bounce_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesBounceRate]", - "deferral_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesDeferralRate]", - "complaint_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesComplaintRate]", - "human_open_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesHumanOpenRate]", - "human_click_rate": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesHumanClickRate]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItemPrevious = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItemPrevious", - { - "metrics": "Required[AnalyticsResponsePayloadTimeSeriesItemPreviousMetrics]", - "rate_bases": "Required[AnalyticsResponsePayloadTimeSeriesItemPreviousRateBases]", - }, -) - - -AnalyticsResponsePayloadTimeSeriesItem = TypedDict( - "AnalyticsResponsePayloadTimeSeriesItem", - { - "metrics": "Required[AnalyticsResponsePayloadTimeSeriesItemMetrics]", - "rate_bases": "Required[AnalyticsResponsePayloadTimeSeriesItemRateBases]", - "previous": "NotRequired[AnalyticsResponsePayloadTimeSeriesItemPrevious]", - "change": "NotRequired[dict[str, dict[str, Any]]]", - "from": "Required[str]", - "to": "Required[str]", - "available": "Required[bool]", - "partial": "Required[bool]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemMetrics = TypedDict( - "AnalyticsResponsePayloadBreakdownItemMetrics", - { - "accepted": "NotRequired[int | None]", - "processed": "NotRequired[int | None]", - "suppressed": "NotRequired[int | None]", - "policy_rejected": "NotRequired[int | None]", - "application_failed": "NotRequired[int | None]", - "mta_accepted": "NotRequired[int | None]", - "canceled": "NotRequired[int | None]", - "messages": "NotRequired[int | None]", - "delivered": "NotRequired[int | None]", - "bounced": "NotRequired[int | None]", - "soft_bounced": "NotRequired[int | None]", - "administratively_bounced": "NotRequired[int | None]", - "deferred_recipients": "NotRequired[int | None]", - "deferred_events": "NotRequired[int | None]", - "delivery_attempts": "NotRequired[int | None]", - "attempted_recipients": "NotRequired[int | None]", - "transport_outcome_recipients": "NotRequired[int | None]", - "effective_delivered": "NotRequired[int | None]", - "open_tracked_delivered": "NotRequired[int | None]", - "click_tracked_delivered": "NotRequired[int | None]", - "out_of_band_bounced_recipients": "NotRequired[int | None]", - "out_of_band_bounce_events": "NotRequired[int | None]", - "complained": "NotRequired[int | None]", - "unsubscribed": "NotRequired[int | None]", - "human_opens": "NotRequired[int | None]", - "human_opens_events": "NotRequired[int | None]", - "human_clicks": "NotRequired[int | None]", - "human_clicks_events": "NotRequired[int | None]", - "machine_opens": "NotRequired[int | None]", - "machine_opens_events": "NotRequired[int | None]", - "machine_clicks": "NotRequired[int | None]", - "machine_clicks_events": "NotRequired[int | None]", - "privacy_opens": "NotRequired[int | None]", - "privacy_opens_events": "NotRequired[int | None]", - "privacy_clicks": "NotRequired[int | None]", - "privacy_clicks_events": "NotRequired[int | None]", - "bot_opens": "NotRequired[int | None]", - "bot_opens_events": "NotRequired[int | None]", - "bot_clicks": "NotRequired[int | None]", - "bot_clicks_events": "NotRequired[int | None]", - "scanner_opens": "NotRequired[int | None]", - "scanner_opens_events": "NotRequired[int | None]", - "scanner_clicks": "NotRequired[int | None]", - "scanner_clicks_events": "NotRequired[int | None]", - "observed_opens": "NotRequired[int | None]", - "observed_opens_events": "NotRequired[int | None]", - "observed_clicks": "NotRequired[int | None]", - "observed_clicks_events": "NotRequired[int | None]", - "delivery_rate": "NotRequired[float | None]", - "effective_delivery_rate": "NotRequired[float | None]", - "bounce_rate": "NotRequired[float | None]", - "deferral_rate": "NotRequired[float | None]", - "complaint_rate": "NotRequired[float | None]", - "human_open_rate": "NotRequired[float | None]", - "human_click_rate": "NotRequired[float | None]", - "processing_latency_p50_ms": "NotRequired[float | None]", - "processing_latency_p95_ms": "NotRequired[float | None]", - "processing_latency_p99_ms": "NotRequired[float | None]", - "processing_latency_samples": "NotRequired[int | None]", - "delivery_latency_p50_ms": "NotRequired[float | None]", - "delivery_latency_p95_ms": "NotRequired[float | None]", - "delivery_latency_p99_ms": "NotRequired[float | None]", - "delivery_latency_samples": "NotRequired[int | None]", - "total_latency_p50_ms": "NotRequired[float | None]", - "total_latency_p95_ms": "NotRequired[float | None]", - "total_latency_p99_ms": "NotRequired[float | None]", - "total_latency_samples": "NotRequired[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemRateBasesDeliveryRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemRateBasesDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemRateBasesEffectiveDeliveryRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemRateBasesEffectiveDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemRateBasesBounceRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemRateBasesBounceRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemRateBasesDeferralRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemRateBasesDeferralRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemRateBasesComplaintRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemRateBasesComplaintRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemRateBasesHumanOpenRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemRateBasesHumanOpenRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemRateBasesHumanClickRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemRateBasesHumanClickRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemRateBases = TypedDict( - "AnalyticsResponsePayloadBreakdownItemRateBases", - { - "delivery_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemRateBasesDeliveryRate]", - "effective_delivery_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemRateBasesEffectiveDeliveryRate]", - "bounce_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemRateBasesBounceRate]", - "deferral_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemRateBasesDeferralRate]", - "complaint_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemRateBasesComplaintRate]", - "human_open_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemRateBasesHumanOpenRate]", - "human_click_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemRateBasesHumanClickRate]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemPreviousMetrics = TypedDict( - "AnalyticsResponsePayloadBreakdownItemPreviousMetrics", - { - "accepted": "NotRequired[int | None]", - "processed": "NotRequired[int | None]", - "suppressed": "NotRequired[int | None]", - "policy_rejected": "NotRequired[int | None]", - "application_failed": "NotRequired[int | None]", - "mta_accepted": "NotRequired[int | None]", - "canceled": "NotRequired[int | None]", - "messages": "NotRequired[int | None]", - "delivered": "NotRequired[int | None]", - "bounced": "NotRequired[int | None]", - "soft_bounced": "NotRequired[int | None]", - "administratively_bounced": "NotRequired[int | None]", - "deferred_recipients": "NotRequired[int | None]", - "deferred_events": "NotRequired[int | None]", - "delivery_attempts": "NotRequired[int | None]", - "attempted_recipients": "NotRequired[int | None]", - "transport_outcome_recipients": "NotRequired[int | None]", - "effective_delivered": "NotRequired[int | None]", - "open_tracked_delivered": "NotRequired[int | None]", - "click_tracked_delivered": "NotRequired[int | None]", - "out_of_band_bounced_recipients": "NotRequired[int | None]", - "out_of_band_bounce_events": "NotRequired[int | None]", - "complained": "NotRequired[int | None]", - "unsubscribed": "NotRequired[int | None]", - "human_opens": "NotRequired[int | None]", - "human_opens_events": "NotRequired[int | None]", - "human_clicks": "NotRequired[int | None]", - "human_clicks_events": "NotRequired[int | None]", - "machine_opens": "NotRequired[int | None]", - "machine_opens_events": "NotRequired[int | None]", - "machine_clicks": "NotRequired[int | None]", - "machine_clicks_events": "NotRequired[int | None]", - "privacy_opens": "NotRequired[int | None]", - "privacy_opens_events": "NotRequired[int | None]", - "privacy_clicks": "NotRequired[int | None]", - "privacy_clicks_events": "NotRequired[int | None]", - "bot_opens": "NotRequired[int | None]", - "bot_opens_events": "NotRequired[int | None]", - "bot_clicks": "NotRequired[int | None]", - "bot_clicks_events": "NotRequired[int | None]", - "scanner_opens": "NotRequired[int | None]", - "scanner_opens_events": "NotRequired[int | None]", - "scanner_clicks": "NotRequired[int | None]", - "scanner_clicks_events": "NotRequired[int | None]", - "observed_opens": "NotRequired[int | None]", - "observed_opens_events": "NotRequired[int | None]", - "observed_clicks": "NotRequired[int | None]", - "observed_clicks_events": "NotRequired[int | None]", - "delivery_rate": "NotRequired[float | None]", - "effective_delivery_rate": "NotRequired[float | None]", - "bounce_rate": "NotRequired[float | None]", - "deferral_rate": "NotRequired[float | None]", - "complaint_rate": "NotRequired[float | None]", - "human_open_rate": "NotRequired[float | None]", - "human_click_rate": "NotRequired[float | None]", - "processing_latency_p50_ms": "NotRequired[float | None]", - "processing_latency_p95_ms": "NotRequired[float | None]", - "processing_latency_p99_ms": "NotRequired[float | None]", - "processing_latency_samples": "NotRequired[int | None]", - "delivery_latency_p50_ms": "NotRequired[float | None]", - "delivery_latency_p95_ms": "NotRequired[float | None]", - "delivery_latency_p99_ms": "NotRequired[float | None]", - "delivery_latency_samples": "NotRequired[int | None]", - "total_latency_p50_ms": "NotRequired[float | None]", - "total_latency_p95_ms": "NotRequired[float | None]", - "total_latency_p99_ms": "NotRequired[float | None]", - "total_latency_samples": "NotRequired[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemPreviousRateBasesDeliveryRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemPreviousRateBasesDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemPreviousRateBasesEffectiveDeliveryRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemPreviousRateBasesEffectiveDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemPreviousRateBasesBounceRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemPreviousRateBasesBounceRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemPreviousRateBasesDeferralRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemPreviousRateBasesDeferralRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemPreviousRateBasesComplaintRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemPreviousRateBasesComplaintRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemPreviousRateBasesHumanOpenRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemPreviousRateBasesHumanOpenRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemPreviousRateBasesHumanClickRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemPreviousRateBasesHumanClickRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemPreviousRateBases = TypedDict( - "AnalyticsResponsePayloadBreakdownItemPreviousRateBases", - { - "delivery_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemPreviousRateBasesDeliveryRate]", - "effective_delivery_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemPreviousRateBasesEffectiveDeliveryRate]", - "bounce_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemPreviousRateBasesBounceRate]", - "deferral_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemPreviousRateBasesDeferralRate]", - "complaint_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemPreviousRateBasesComplaintRate]", - "human_open_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemPreviousRateBasesHumanOpenRate]", - "human_click_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemPreviousRateBasesHumanClickRate]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemPrevious = TypedDict( - "AnalyticsResponsePayloadBreakdownItemPrevious", - { - "metrics": "Required[AnalyticsResponsePayloadBreakdownItemPreviousMetrics]", - "rate_bases": "Required[AnalyticsResponsePayloadBreakdownItemPreviousRateBases]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemMetrics = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemMetrics", - { - "accepted": "NotRequired[int | None]", - "processed": "NotRequired[int | None]", - "suppressed": "NotRequired[int | None]", - "policy_rejected": "NotRequired[int | None]", - "application_failed": "NotRequired[int | None]", - "mta_accepted": "NotRequired[int | None]", - "canceled": "NotRequired[int | None]", - "messages": "NotRequired[int | None]", - "delivered": "NotRequired[int | None]", - "bounced": "NotRequired[int | None]", - "soft_bounced": "NotRequired[int | None]", - "administratively_bounced": "NotRequired[int | None]", - "deferred_recipients": "NotRequired[int | None]", - "deferred_events": "NotRequired[int | None]", - "delivery_attempts": "NotRequired[int | None]", - "attempted_recipients": "NotRequired[int | None]", - "transport_outcome_recipients": "NotRequired[int | None]", - "effective_delivered": "NotRequired[int | None]", - "open_tracked_delivered": "NotRequired[int | None]", - "click_tracked_delivered": "NotRequired[int | None]", - "out_of_band_bounced_recipients": "NotRequired[int | None]", - "out_of_band_bounce_events": "NotRequired[int | None]", - "complained": "NotRequired[int | None]", - "unsubscribed": "NotRequired[int | None]", - "human_opens": "NotRequired[int | None]", - "human_opens_events": "NotRequired[int | None]", - "human_clicks": "NotRequired[int | None]", - "human_clicks_events": "NotRequired[int | None]", - "machine_opens": "NotRequired[int | None]", - "machine_opens_events": "NotRequired[int | None]", - "machine_clicks": "NotRequired[int | None]", - "machine_clicks_events": "NotRequired[int | None]", - "privacy_opens": "NotRequired[int | None]", - "privacy_opens_events": "NotRequired[int | None]", - "privacy_clicks": "NotRequired[int | None]", - "privacy_clicks_events": "NotRequired[int | None]", - "bot_opens": "NotRequired[int | None]", - "bot_opens_events": "NotRequired[int | None]", - "bot_clicks": "NotRequired[int | None]", - "bot_clicks_events": "NotRequired[int | None]", - "scanner_opens": "NotRequired[int | None]", - "scanner_opens_events": "NotRequired[int | None]", - "scanner_clicks": "NotRequired[int | None]", - "scanner_clicks_events": "NotRequired[int | None]", - "observed_opens": "NotRequired[int | None]", - "observed_opens_events": "NotRequired[int | None]", - "observed_clicks": "NotRequired[int | None]", - "observed_clicks_events": "NotRequired[int | None]", - "delivery_rate": "NotRequired[float | None]", - "effective_delivery_rate": "NotRequired[float | None]", - "bounce_rate": "NotRequired[float | None]", - "deferral_rate": "NotRequired[float | None]", - "complaint_rate": "NotRequired[float | None]", - "human_open_rate": "NotRequired[float | None]", - "human_click_rate": "NotRequired[float | None]", - "processing_latency_p50_ms": "NotRequired[float | None]", - "processing_latency_p95_ms": "NotRequired[float | None]", - "processing_latency_p99_ms": "NotRequired[float | None]", - "processing_latency_samples": "NotRequired[int | None]", - "delivery_latency_p50_ms": "NotRequired[float | None]", - "delivery_latency_p95_ms": "NotRequired[float | None]", - "delivery_latency_p99_ms": "NotRequired[float | None]", - "delivery_latency_samples": "NotRequired[int | None]", - "total_latency_p50_ms": "NotRequired[float | None]", - "total_latency_p95_ms": "NotRequired[float | None]", - "total_latency_p99_ms": "NotRequired[float | None]", - "total_latency_samples": "NotRequired[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesDeliveryRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesEffectiveDeliveryRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesEffectiveDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesBounceRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesBounceRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesDeferralRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesDeferralRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesComplaintRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesComplaintRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesHumanOpenRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesHumanOpenRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesHumanClickRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesHumanClickRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemRateBases = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemRateBases", - { - "delivery_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesDeliveryRate]", - "effective_delivery_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesEffectiveDeliveryRate]", - "bounce_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesBounceRate]", - "deferral_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesDeferralRate]", - "complaint_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesComplaintRate]", - "human_open_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesHumanOpenRate]", - "human_click_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesHumanClickRate]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemPreviousMetrics = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemPreviousMetrics", - { - "accepted": "NotRequired[int | None]", - "processed": "NotRequired[int | None]", - "suppressed": "NotRequired[int | None]", - "policy_rejected": "NotRequired[int | None]", - "application_failed": "NotRequired[int | None]", - "mta_accepted": "NotRequired[int | None]", - "canceled": "NotRequired[int | None]", - "messages": "NotRequired[int | None]", - "delivered": "NotRequired[int | None]", - "bounced": "NotRequired[int | None]", - "soft_bounced": "NotRequired[int | None]", - "administratively_bounced": "NotRequired[int | None]", - "deferred_recipients": "NotRequired[int | None]", - "deferred_events": "NotRequired[int | None]", - "delivery_attempts": "NotRequired[int | None]", - "attempted_recipients": "NotRequired[int | None]", - "transport_outcome_recipients": "NotRequired[int | None]", - "effective_delivered": "NotRequired[int | None]", - "open_tracked_delivered": "NotRequired[int | None]", - "click_tracked_delivered": "NotRequired[int | None]", - "out_of_band_bounced_recipients": "NotRequired[int | None]", - "out_of_band_bounce_events": "NotRequired[int | None]", - "complained": "NotRequired[int | None]", - "unsubscribed": "NotRequired[int | None]", - "human_opens": "NotRequired[int | None]", - "human_opens_events": "NotRequired[int | None]", - "human_clicks": "NotRequired[int | None]", - "human_clicks_events": "NotRequired[int | None]", - "machine_opens": "NotRequired[int | None]", - "machine_opens_events": "NotRequired[int | None]", - "machine_clicks": "NotRequired[int | None]", - "machine_clicks_events": "NotRequired[int | None]", - "privacy_opens": "NotRequired[int | None]", - "privacy_opens_events": "NotRequired[int | None]", - "privacy_clicks": "NotRequired[int | None]", - "privacy_clicks_events": "NotRequired[int | None]", - "bot_opens": "NotRequired[int | None]", - "bot_opens_events": "NotRequired[int | None]", - "bot_clicks": "NotRequired[int | None]", - "bot_clicks_events": "NotRequired[int | None]", - "scanner_opens": "NotRequired[int | None]", - "scanner_opens_events": "NotRequired[int | None]", - "scanner_clicks": "NotRequired[int | None]", - "scanner_clicks_events": "NotRequired[int | None]", - "observed_opens": "NotRequired[int | None]", - "observed_opens_events": "NotRequired[int | None]", - "observed_clicks": "NotRequired[int | None]", - "observed_clicks_events": "NotRequired[int | None]", - "delivery_rate": "NotRequired[float | None]", - "effective_delivery_rate": "NotRequired[float | None]", - "bounce_rate": "NotRequired[float | None]", - "deferral_rate": "NotRequired[float | None]", - "complaint_rate": "NotRequired[float | None]", - "human_open_rate": "NotRequired[float | None]", - "human_click_rate": "NotRequired[float | None]", - "processing_latency_p50_ms": "NotRequired[float | None]", - "processing_latency_p95_ms": "NotRequired[float | None]", - "processing_latency_p99_ms": "NotRequired[float | None]", - "processing_latency_samples": "NotRequired[int | None]", - "delivery_latency_p50_ms": "NotRequired[float | None]", - "delivery_latency_p95_ms": "NotRequired[float | None]", - "delivery_latency_p99_ms": "NotRequired[float | None]", - "delivery_latency_samples": "NotRequired[int | None]", - "total_latency_p50_ms": "NotRequired[float | None]", - "total_latency_p95_ms": "NotRequired[float | None]", - "total_latency_p99_ms": "NotRequired[float | None]", - "total_latency_samples": "NotRequired[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesDeliveryRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesEffectiveDeliveryRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesEffectiveDeliveryRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesBounceRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesBounceRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesDeferralRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesDeferralRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesComplaintRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesComplaintRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesHumanOpenRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesHumanOpenRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesHumanClickRate = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesHumanClickRate", - { - "numerator": "Required[int | None]", - "denominator": "Required[int | None]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBases = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBases", - { - "delivery_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesDeliveryRate]", - "effective_delivery_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesEffectiveDeliveryRate]", - "bounce_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesBounceRate]", - "deferral_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesDeferralRate]", - "complaint_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesComplaintRate]", - "human_open_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesHumanOpenRate]", - "human_click_rate": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesHumanClickRate]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItemPrevious = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItemPrevious", - { - "metrics": "Required[AnalyticsResponsePayloadBreakdownItemTrendItemPreviousMetrics]", - "rate_bases": "Required[AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBases]", - }, -) - - -AnalyticsResponsePayloadBreakdownItemTrendItem = TypedDict( - "AnalyticsResponsePayloadBreakdownItemTrendItem", - { - "metrics": "Required[AnalyticsResponsePayloadBreakdownItemTrendItemMetrics]", - "rate_bases": "Required[AnalyticsResponsePayloadBreakdownItemTrendItemRateBases]", - "previous": "NotRequired[AnalyticsResponsePayloadBreakdownItemTrendItemPrevious]", - "change": "NotRequired[dict[str, dict[str, Any]]]", - "from": "Required[str]", - "to": "Required[str]", - "available": "Required[bool]", - "partial": "Required[bool]", - }, -) - - -AnalyticsResponsePayloadBreakdownItem = TypedDict( - "AnalyticsResponsePayloadBreakdownItem", - { - "metrics": "Required[AnalyticsResponsePayloadBreakdownItemMetrics]", - "rate_bases": "Required[AnalyticsResponsePayloadBreakdownItemRateBases]", - "previous": "NotRequired[AnalyticsResponsePayloadBreakdownItemPrevious]", - "change": "NotRequired[dict[str, dict[str, Any]]]", - "dimensions": "Required[dict[str, str | None]]", - "trend": "NotRequired[list[AnalyticsResponsePayloadBreakdownItemTrendItem]]", - }, -) - - -AnalyticsResponsePayload = TypedDict( - "AnalyticsResponsePayload", - { - "summary": "NotRequired[AnalyticsResponsePayloadSummary]", - "time_series": "NotRequired[list[AnalyticsResponsePayloadTimeSeriesItem]]", - "breakdown": "NotRequired[list[AnalyticsResponsePayloadBreakdownItem]]", - }, -) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 06dbd57..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Lettermint SDK.""" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index faf90c0..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Pytest configuration and fixtures.""" - -import pytest - - -@pytest.fixture -def api_token() -> str: - """Provide a test API token.""" - return "test-api-token" - - -@pytest.fixture -def webhook_secret() -> str: - """Provide a test webhook secret.""" - return "test-webhook-secret" diff --git a/tests/test_analytics_forwarding.py b/tests/test_analytics_forwarding.py deleted file mode 100644 index df99030..0000000 --- a/tests/test_analytics_forwarding.py +++ /dev/null @@ -1,99 +0,0 @@ -import json - -import pytest -import respx -from httpx import Response - -from lettermint import AsyncLettermint, Lettermint, types - -PAYLOAD = { - "metrics": ["accepted", "delivered"], - "include": ["summary"], - "interval": "day", - "filters": [{"dimension": "project", "operator": "eq", "values": ["project/id"]}], - "sort": {"metric": "accepted", "direction": "desc"}, - "limit": 10, -} -RESOURCE = {"destination": None, "verified": False, "verified_at": None} - - -def routes(): - base = "https://api.lettermint.co/v1" - path = base + "/projects/project%2Fid/report-forwarding" - return [ - respx.post(base + "/analytics").mock( - return_value=Response(200, json={"data": {}, "meta": {}, "pagination": ["cursor"]}) - ), - respx.get(path).mock(return_value=Response(200, json={"data": RESOURCE})), - respx.put(path).mock(return_value=Response(200, json={"data": RESOURCE})), - respx.post(path + "/verify").mock(return_value=Response(200, json={"data": RESOURCE})), - respx.post(path + "/resend-code").mock(return_value=Response(200, json={"data": RESOURCE})), - respx.delete(path).mock(return_value=Response(204)), - ] - - -def assert_requests(registered): - for route in registered: - assert route.call_count == 1 - request = route.calls.last.request - assert request.headers["authorization"] == "Bearer team-token" - assert "x-lettermint-token" not in request.headers - assert json.loads(registered[0].calls.last.request.content) == PAYLOAD - assert json.loads(registered[2].calls.last.request.content) == { - "destination": "reports@example.com" - } - assert json.loads(registered[3].calls.last.request.content) == {"code": "123456"} - - -@respx.mock -def test_sync_analytics_and_forwarding(): - registered = routes() - with Lettermint.api("team-token") as api: - api.analytics(PAYLOAD) - assert api.projects.retrieve_report_forwarding("project/id")["data"] == RESOURCE - api.projects.update_report_forwarding("project/id", {"destination": "reports@example.com"}) - api.projects.verify_report_forwarding("project/id", {"code": "123456"}) - api.projects.resend_report_forwarding_code("project/id") - assert api.projects.delete_report_forwarding("project/id") is None - assert_requests(registered) - - -@pytest.mark.asyncio -@respx.mock -async def test_async_analytics_and_forwarding(): - registered = routes() - async with AsyncLettermint.api("team-token") as api: - await api.analytics(PAYLOAD) - assert (await api.projects.retrieve_report_forwarding("project/id"))["data"] == RESOURCE - await api.projects.update_report_forwarding( - "project/id", {"destination": "reports@example.com"} - ) - await api.projects.verify_report_forwarding("project/id", {"code": "123456"}) - await api.projects.resend_report_forwarding_code("project/id") - assert await api.projects.delete_report_forwarding("project/id") is None - assert_requests(registered) - - -def test_public_types_and_new_fields(): - for name in ("RescheduleMessageResponse", "CancelScheduledMessageResponse", "CursorPaginator"): - assert hasattr(types, name) - assert "api_token" in types.ProjectStoreResponse.__annotations__ - assert "delivery_mode" in types.ProjectListData.__annotations__ - assert "delivery_mode_filter" in types.WebhookListData.__annotations__ - assert "sandbox" in types.WebhookDeliveryListData.__annotations__ - assert "ticket_identifier" in types.SuppressionDestroyResponse.__annotations__ - assert set(types.AnalyticsRequest.__annotations__) == { - "metrics", - "from", - "to", - "timezone", - "include", - "group_by", - "filters", - "interval", - "compare", - "include_trend", - "sort", - "limit", - "cursor", - } diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py deleted file mode 100644 index 259ea5c..0000000 --- a/tests/test_api_surface.py +++ /dev/null @@ -1,419 +0,0 @@ -"""Tests for v2 API surface and full API endpoint coverage.""" - -from __future__ import annotations - -import json -from typing import get_args, get_type_hints - -import pytest -import respx -from httpx import Response - -from lettermint import AsyncLettermint, Lettermint -from lettermint import types as lm_types - - -class TestV2Entrypoints: - @respx.mock - def test_email_entrypoint_uses_sending_auth_and_raw_ping(self) -> None: - route = respx.get("https://api.lettermint.co/v1/ping").mock( - return_value=Response(200, text=" pong") - ) - - with Lettermint.email("sending-token") as email: - assert email.ping() == "pong" - - request = route.calls.last.request - assert request.headers["x-lettermint-token"] == "sending-token" - assert "authorization" not in request.headers - - @respx.mock - def test_api_entrypoint_uses_bearer_auth_and_raw_ping(self) -> None: - route = respx.get("https://api.lettermint.co/v1/ping").mock( - return_value=Response(200, text=" pong") - ) - - with Lettermint.api("api-token") as api: - assert api.ping() == "pong" - - request = route.calls.last.request - assert request.headers["authorization"] == "Bearer api-token" - assert "x-lettermint-token" not in request.headers - - @respx.mock - def test_api_blocked_file_types_uses_bearer_auth(self) -> None: - route = respx.get("https://api.lettermint.co/v1/blocked-file-types").mock( - return_value=Response( - 200, - json={ - "extensions": ["exe"], - "mime_types": ["application/x-msdownload"], - }, - ) - ) - - with Lettermint.api("api-token") as api: - response = api.blocked_file_types() - - assert response["extensions"] == ["exe"] - request = route.calls.last.request - assert request.headers["authorization"] == "Bearer api-token" - assert "x-lettermint-token" not in request.headers - - @respx.mock - @pytest.mark.asyncio - async def test_async_entrypoints_use_matching_auth(self) -> None: - email_route = respx.get("https://api.lettermint.co/v1/ping").mock( - return_value=Response(200, text="pong") - ) - - async with AsyncLettermint.email("sending-token") as email: - assert await email.ping() == "pong" - - assert email_route.calls.last.request.headers["x-lettermint-token"] == "sending-token" - - api_route = respx.get("https://api.lettermint.co/v1/ping").mock( - return_value=Response(200, text="pong") - ) - - async with AsyncLettermint.api("api-token") as api: - assert await api.ping() == "pong" - - assert api_route.calls.last.request.headers["authorization"] == "Bearer api-token" - - def test_async_api_exposes_full_endpoint_groups(self) -> None: - api = AsyncLettermint.api("api-token") - - assert hasattr(api, "domains") - assert hasattr(api, "messages") - assert hasattr(api, "projects") - assert hasattr(api, "routes") - assert hasattr(api, "stats") - assert hasattr(api, "suppressions") - assert hasattr(api, "team") - assert hasattr(api, "webhooks") - assert hasattr(api, "blocked_file_types") - assert hasattr(api.team, "roles") - assert hasattr(api.team, "member") - assert hasattr(api.team, "update_member_assignment") - - -class TestSendingEndpoint: - @respx.mock - def test_send_batch_posts_list_payload(self) -> None: - route = respx.post("https://api.lettermint.co/v1/send/batch").mock( - return_value=Response(200, json=[{"message_id": "msg_123", "status": "queued"}]) - ) - - with Lettermint.email("sending-token") as email: - response = email.idempotency_key("batch-key").send_batch( - [ - { - "from": "sender@example.com", - "to": ["recipient@example.com"], - "subject": "Hello", - } - ] - ) - - assert response[0]["message_id"] == "msg_123" - assert json.loads(route.calls.last.request.content)[0]["subject"] == "Hello" - assert route.calls.last.request.headers["Idempotency-Key"] == "batch-key" - - -class TestFullApiEndpoints: - @respx.mock - def test_domain_endpoints_map_requests(self) -> None: - list_route = respx.get("https://api.lettermint.co/v1/domains").mock( - return_value=Response(200, json={"data": []}) - ) - retrieve_route = respx.get("https://api.lettermint.co/v1/domains/domain%201").mock( - return_value=Response(200, json={"data": {"id": "domain 1"}}) - ) - create_route = respx.post("https://api.lettermint.co/v1/domains").mock( - return_value=Response(201, json={"data": {"id": "domain 1"}}) - ) - delete_route = respx.delete("https://api.lettermint.co/v1/domains/domain%201").mock( - return_value=Response(200, json={"deleted": True}) - ) - - with Lettermint.api("api-token") as api: - assert api.domains.list({"page[size]": "5"})["data"] == [] - assert api.domains.retrieve("domain 1")["data"]["id"] == "domain 1" - assert api.domains.create({"domain": "example.com"})["data"]["id"] == "domain 1" - assert api.domains.delete("domain 1")["deleted"] is True - - assert list_route.calls.last.request.url.params["page[size]"] == "5" - assert retrieve_route.called - assert json.loads(create_route.calls.last.request.content) == {"domain": "example.com"} - assert delete_route.called - - @respx.mock - def test_message_raw_body_endpoints(self) -> None: - source_route = respx.get("https://api.lettermint.co/v1/messages/message-id/source").mock( - return_value=Response(200, text="raw source") - ) - html_route = respx.get("https://api.lettermint.co/v1/messages/message-id/html").mock( - return_value=Response(200, text="

Hello

") - ) - text_route = respx.get("https://api.lettermint.co/v1/messages/message-id/text").mock( - return_value=Response(200, text="Hello") - ) - - with Lettermint.api("api-token") as api: - assert api.messages.source("message-id") == "raw source" - assert api.messages.html("message-id") == "

Hello

" - assert api.messages.text("message-id") == "Hello" - - assert source_route.called - assert html_route.called - assert text_route.called - - @respx.mock - def test_scheduled_message_endpoints(self) -> None: - reschedule_route = respx.patch("https://api.lettermint.co/v1/messages/message%2Fid").mock( - return_value=Response( - 200, - json={ - "message_id": "message/id", - "status": "scheduled", - "scheduled_at": "2026-08-27T09:00:00Z", - }, - ) - ) - cancel_route = respx.post("https://api.lettermint.co/v1/messages/message%2Fid/cancel").mock( - return_value=Response( - 200, - json={"message_id": "message/id", "status": "canceled", "scheduled_at": None}, - ) - ) - process_route = respx.post( - "https://api.lettermint.co/v1/messages/message%2Fid/process" - ).mock( - return_value=Response( - 202, - json={ - "data": { - "message_id": "message/id", - "status": "queued", - "webhook_target_count": 1, - } - }, - ) - ) - - with Lettermint.api("api-token") as api: - assert ( - api.messages.reschedule("message/id", {"scheduled_at": "2026-08-27T09:00:00Z"})[ - "status" - ] - == "scheduled" - ) - assert api.messages.cancel("message/id")["status"] == "canceled" - assert api.messages.process("message/id")["data"]["status"] == "queued" - - assert json.loads(reschedule_route.calls.last.request.content) == { - "scheduled_at": "2026-08-27T09:00:00Z" - } - assert cancel_route.called - assert process_route.called - - @respx.mock - @pytest.mark.asyncio - @pytest.mark.parametrize("endpoint_group", ["messages", "domains"]) - async def test_async_scheduled_message_endpoints(self, endpoint_group: str) -> None: - reschedule_route = respx.patch("https://api.lettermint.co/v1/messages/message%2Fid").mock( - return_value=Response( - 200, - json={ - "message_id": "message/id", - "status": "scheduled", - "scheduled_at": "2026-08-27T09:00:00Z", - }, - ) - ) - cancel_route = respx.post("https://api.lettermint.co/v1/messages/message%2Fid/cancel").mock( - return_value=Response( - 200, - json={"message_id": "message/id", "status": "canceled", "scheduled_at": None}, - ) - ) - process_route = respx.post( - "https://api.lettermint.co/v1/messages/message%2Fid/process" - ).mock( - return_value=Response( - 202, - json={ - "data": { - "message_id": "message/id", - "status": "queued", - "webhook_target_count": 1, - } - }, - ) - ) - - async with AsyncLettermint.api("api-token") as api: - endpoint = getattr(api, endpoint_group) - assert ( - await endpoint.reschedule("message/id", {"scheduled_at": "2026-08-27T09:00:00Z"}) - )["status"] == "scheduled" - canceled: lm_types.RescheduleMessageResponse = await endpoint.cancel("message/id") - assert canceled["message_id"] == "message/id" - assert canceled["status"] == "canceled" - assert canceled["scheduled_at"] is None - assert (await endpoint.process("message/id"))["data"]["status"] == "queued" - - assert reschedule_route.called - assert cancel_route.called - assert process_route.called - assert json.loads(reschedule_route.calls.last.request.content) == { - "scheduled_at": "2026-08-27T09:00:00Z" - } - for route in (reschedule_route, cancel_route, process_route): - assert route.call_count == 1 - assert route.calls.last.request.headers["authorization"] == "Bearer api-token" - assert json.loads(cancel_route.calls.last.request.content) == {} - assert json.loads(process_route.calls.last.request.content) == {} - - @respx.mock - def test_team_role_and_member_assignment_endpoints(self) -> None: - roles_route = respx.get("https://api.lettermint.co/v1/team/roles").mock( - return_value=Response(200, json={"data": []}) - ) - member_route = respx.get("https://api.lettermint.co/v1/team/members/user%2Fid").mock( - return_value=Response(200, json={"id": "user/id"}) - ) - assignment_route = respx.put( - "https://api.lettermint.co/v1/team/members/user%2Fid/assignment" - ).mock(return_value=Response(200, json={"id": "user/id"})) - assignment: lm_types.TeamMembersAssignmentUpdateRequest = { - "role_id": "role_123", - "project_access": {"scope": "all"}, - } - - with Lettermint.api("api-token") as api: - assert api.team.roles()["data"] == [] - assert api.team.member("user/id")["id"] == "user/id" - assert api.team.update_member_assignment("user/id", assignment)["id"] == "user/id" - - assert roles_route.called - assert member_route.called - assert json.loads(assignment_route.calls.last.request.content) == assignment - - def test_documented_operations_are_exposed(self) -> None: - operations = [ - (Lettermint.api("token"), "analytics"), - (Lettermint.api("token").projects, "retrieve_report_forwarding"), - (Lettermint.api("token").projects, "update_report_forwarding"), - (Lettermint.api("token").projects, "delete_report_forwarding"), - (Lettermint.api("token").projects, "verify_report_forwarding"), - (Lettermint.api("token").projects, "resend_report_forwarding_code"), - (Lettermint.email("token"), "send"), - (Lettermint.email("token"), "send_batch"), - (Lettermint.email("token"), "ping"), - (Lettermint.api("token"), "ping"), - (Lettermint.api("token"), "blocked_file_types"), - (Lettermint.api("token").domains, "list"), - (Lettermint.api("token").domains, "create"), - (Lettermint.api("token").domains, "retrieve"), - (Lettermint.api("token").domains, "delete"), - (Lettermint.api("token").domains, "verify_dns_records"), - (Lettermint.api("token").domains, "verify_dns_record"), - (Lettermint.api("token").domains, "update_projects"), - (Lettermint.api("token").messages, "list"), - (Lettermint.api("token").messages, "retrieve"), - (Lettermint.api("token").messages, "reschedule"), - (Lettermint.api("token").messages, "cancel"), - (Lettermint.api("token").messages, "process"), - (Lettermint.api("token").messages, "events"), - (Lettermint.api("token").messages, "source"), - (Lettermint.api("token").messages, "html"), - (Lettermint.api("token").messages, "text"), - (Lettermint.api("token").projects, "list"), - (Lettermint.api("token").projects, "create"), - (Lettermint.api("token").projects, "retrieve"), - (Lettermint.api("token").projects, "update"), - (Lettermint.api("token").projects, "delete"), - (Lettermint.api("token").projects, "rotate_token"), - (Lettermint.api("token").projects, "routes"), - (Lettermint.api("token").projects, "create_route"), - (Lettermint.api("token").routes, "retrieve"), - (Lettermint.api("token").routes, "update"), - (Lettermint.api("token").routes, "delete"), - (Lettermint.api("token").routes, "verify_inbound_domain"), - (Lettermint.api("token").stats, "retrieve"), - (Lettermint.api("token").suppressions, "list"), - (Lettermint.api("token").suppressions, "create"), - (Lettermint.api("token").suppressions, "delete"), - (Lettermint.api("token").team, "retrieve"), - (Lettermint.api("token").team, "update"), - (Lettermint.api("token").team, "usage"), - (Lettermint.api("token").team, "roles"), - (Lettermint.api("token").team, "members"), - (Lettermint.api("token").team, "member"), - (Lettermint.api("token").team, "update_member_assignment"), - (Lettermint.api("token").webhooks, "list"), - (Lettermint.api("token").webhooks, "create"), - (Lettermint.api("token").webhooks, "retrieve"), - (Lettermint.api("token").webhooks, "update"), - (Lettermint.api("token").webhooks, "delete"), - (Lettermint.api("token").webhooks, "test"), - (Lettermint.api("token").webhooks, "regenerate_secret"), - (Lettermint.api("token").webhooks, "deliveries"), - (Lettermint.api("token").webhooks, "delivery"), - ] - - missing = [method for endpoint, method in operations if not hasattr(endpoint, method)] - - assert len(operations) == 59 - assert missing == [] - - def test_generated_types_match_current_team_schema(self) -> None: - assert "auto_replied" in get_args(lm_types.MessageEventType) - assert "message.auto_replied" in get_args(lm_types.WebhookEvent) - assert "admin" in get_args(lm_types.BuiltInTeamRole) - assert "members:manage" in get_args(lm_types.RbacPermission) - assert "team" in get_args(lm_types.SuppressionScope) - - assert "short_token" in lm_types.StoreProjectData.__annotations__ - assert "redact_email_content" in lm_types.ProjectData.__annotations__ - assert "redact_email_content" in lm_types.UpdateProjectData.__annotations__ - assert hasattr(lm_types, "UpdateRouteSettingsData") - assert hasattr(lm_types, "UpdateRouteInboundSettingsData") - assert hasattr(lm_types, "BlockedFileTypesResponse") - assert "extensions" in lm_types.BlockedFileTypesResponse.__annotations__ - assert "mime_types" in lm_types.BlockedFileTypesResponse.__annotations__ - assert "redact_email_content" in lm_types.UpdateRouteSettingsData.__annotations__ - assert "generate_plaintext_fallback" in lm_types.UpdateRouteSettingsData.__annotations__ - assert "inbound_spam_threshold" in lm_types.UpdateRouteInboundSettingsData.__annotations__ - assert "included_volume" in lm_types.TeamData.__annotations__ - assert "assignable" in lm_types.TeamRoleData.__annotations__ - assert "role_id" in lm_types.UpdateTeamMemberAssignmentData.__annotations__ - assert hasattr(lm_types, "RescheduleMessageRequest") - assert hasattr(lm_types, "RescheduleMessageResponse") - assert hasattr(lm_types, "CancelScheduledMessageResponse") - assert hasattr(lm_types, "ProcessInboundMessageResponse") - assert hasattr(lm_types, "CursorPaginator") - assert ( - get_type_hints(type(Lettermint.api("token").messages).cancel)["return"] - is lm_types.CancelScheduledMessageResponse - ) - - def test_generated_types_include_sandbox_contracts(self) -> None: - assert set(get_args(lm_types.DeliveryMode)) == {"live", "sandbox"} - assert "clicked" in get_args(lm_types.SandboxResult) - assert set(get_args(lm_types.WebhookDeliveryModeFilter)) == {"live", "sandbox", "both"} - - assert "sandbox_result" in lm_types.SendMailRequest.__annotations__ - assert "sandbox" in lm_types.SendMailResponse.__annotations__ - assert "sandbox_result" in lm_types.SendMailResponse.__annotations__ - assert "delivery_mode" in lm_types.ProjectData.__annotations__ - assert "delivery_mode" in lm_types.StoreProjectData.__annotations__ - assert "delivery_mode" in lm_types.UpdateProjectData.__annotations__ - assert "delivery_mode" in lm_types.MessageData.__annotations__ - assert "sandbox_result" in lm_types.MessageData.__annotations__ - assert "delivery_mode_filter" in lm_types.StoreWebhookData.__annotations__ - assert "delivery_mode_filter" in lm_types.UpdateWebhookData.__annotations__ - assert "delivery_mode_filter" in lm_types.WebhookData.__annotations__ - assert "sandbox" in lm_types.WebhookDeliveryData.__annotations__ diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index 02fb0a6..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Tests for the HTTP client.""" - -import pytest -import respx -from httpx import Response - -from lettermint.client import AsyncLettermintClient, LettermintClient -from lettermint.exceptions import ( - ClientError, - HttpRequestError, - ValidationError, -) - - -class TestLettermintClientSync: - """Tests for the synchronous HTTP client.""" - - @respx.mock - def test_get_request(self) -> None: - """Test GET request.""" - route = respx.get("https://api.lettermint.co/v1/test").mock( - return_value=Response(200, json={"result": "success"}) - ) - - client = LettermintClient(api_token="test-token") - try: - result = client.get("/test") - assert result == {"result": "success"} - assert route.called - assert route.calls.last.request.headers["x-lettermint-token"] == "test-token" - finally: - client.close() - - @respx.mock - def test_post_request(self) -> None: - """Test POST request.""" - route = respx.post("https://api.lettermint.co/v1/test").mock( - return_value=Response(200, json={"result": "created"}) - ) - - client = LettermintClient(api_token="test-token") - try: - result = client.post("/test", data={"key": "value"}) - assert result == {"result": "created"} - - import json - - body = json.loads(route.calls.last.request.content) - assert body == {"key": "value"} - finally: - client.close() - - @respx.mock - def test_put_request(self) -> None: - """Test PUT request.""" - respx.put("https://api.lettermint.co/v1/test").mock( - return_value=Response(200, json={"result": "updated"}) - ) - - client = LettermintClient(api_token="test-token") - try: - result = client.put("/test", data={"key": "value"}) - assert result == {"result": "updated"} - finally: - client.close() - - @respx.mock - def test_delete_request(self) -> None: - """Test DELETE request.""" - respx.delete("https://api.lettermint.co/v1/test").mock( - return_value=Response(200, json={"result": "deleted"}) - ) - - client = LettermintClient(api_token="test-token") - try: - result = client.delete("/test") - assert result == {"result": "deleted"} - finally: - client.close() - - @respx.mock - def test_custom_base_url(self) -> None: - """Test custom base URL.""" - route = respx.get("https://custom.api.com/v2/test").mock( - return_value=Response(200, json={"result": "success"}) - ) - - client = LettermintClient(api_token="test-token", base_url="https://custom.api.com/v2") - try: - client.get("/test") - assert route.called - finally: - client.close() - - @respx.mock - def test_additional_headers(self) -> None: - """Test additional request headers.""" - route = respx.get("https://api.lettermint.co/v1/test").mock( - return_value=Response(200, json={"result": "success"}) - ) - - client = LettermintClient(api_token="test-token") - try: - client.get("/test", headers={"X-Custom": "header"}) - assert route.calls.last.request.headers["X-Custom"] == "header" - finally: - client.close() - - @respx.mock - def test_validation_error_422(self) -> None: - """Test 422 validation error handling.""" - respx.post("https://api.lettermint.co/v1/test").mock( - return_value=Response(422, json={"error": "DailyLimitExceeded"}) - ) - - client = LettermintClient(api_token="test-token") - try: - with pytest.raises(ValidationError) as exc_info: - client.post("/test", data={}) - - assert exc_info.value.status_code == 422 - assert exc_info.value.error_type == "DailyLimitExceeded" - assert exc_info.value.response_body == {"error": "DailyLimitExceeded"} - finally: - client.close() - - @respx.mock - def test_client_error_400(self) -> None: - """Test 400 client error handling.""" - respx.post("https://api.lettermint.co/v1/test").mock( - return_value=Response(400, json={"error": "Bad request"}) - ) - - client = LettermintClient(api_token="test-token") - try: - with pytest.raises(ClientError) as exc_info: - client.post("/test", data={}) - - assert exc_info.value.status_code == 400 - finally: - client.close() - - @respx.mock - def test_http_error_500(self) -> None: - """Test 500 server error handling.""" - respx.get("https://api.lettermint.co/v1/test").mock( - return_value=Response(500, json={"error": "Internal server error"}) - ) - - client = LettermintClient(api_token="test-token") - try: - with pytest.raises(HttpRequestError) as exc_info: - client.get("/test") - - assert exc_info.value.status_code == 500 - finally: - client.close() - - def test_context_manager(self) -> None: - """Test context manager usage.""" - with LettermintClient(api_token="test-token") as client: - assert client._api_token == "test-token" - - -class TestAsyncLettermintClient: - """Tests for the asynchronous HTTP client.""" - - @respx.mock - @pytest.mark.asyncio - async def test_get_request_async(self) -> None: - """Test async GET request.""" - route = respx.get("https://api.lettermint.co/v1/test").mock( - return_value=Response(200, json={"result": "success"}) - ) - - async with AsyncLettermintClient(api_token="test-token") as client: - result = await client.get("/test") - assert result == {"result": "success"} - assert route.called - - @respx.mock - @pytest.mark.asyncio - async def test_post_request_async(self) -> None: - """Test async POST request.""" - respx.post("https://api.lettermint.co/v1/test").mock( - return_value=Response(200, json={"result": "created"}) - ) - - async with AsyncLettermintClient(api_token="test-token") as client: - result = await client.post("/test", data={"key": "value"}) - assert result == {"result": "created"} - - @respx.mock - @pytest.mark.asyncio - async def test_error_handling_async(self) -> None: - """Test async error handling.""" - respx.get("https://api.lettermint.co/v1/test").mock( - return_value=Response(422, json={"error": "ValidationError"}) - ) - - async with AsyncLettermintClient(api_token="test-token") as client: - with pytest.raises(ValidationError): - await client.get("/test") diff --git a/tests/test_email.py b/tests/test_email.py deleted file mode 100644 index 111b044..0000000 --- a/tests/test_email.py +++ /dev/null @@ -1,487 +0,0 @@ -"""Tests for the email endpoint.""" - -import asyncio -import json - -import pytest -import respx -from httpx import Response - -from lettermint import AsyncLettermint, Lettermint -from lettermint.exceptions import ClientError, ValidationError - - -class TestEmailEndpointSync: - """Tests for the synchronous email endpoint.""" - - @respx.mock - def test_send_basic_email(self, api_token: str) -> None: - """Test sending a basic email with required fields.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response( - 200, - json={"message_id": "msg_123", "status": "pending"}, - ) - ) - - with Lettermint(api_token=api_token) as client: - response = ( - client.email.from_("sender@example.com") - .to("recipient@example.com") - .subject("Test Subject") - .send() - ) - - assert response["message_id"] == "msg_123" - assert response["status"] == "pending" - assert route.called - - request = route.calls.last.request - assert request.headers["x-lettermint-token"] == api_token - - @respx.mock - def test_send_with_multiple_recipients(self, api_token: str) -> None: - """Test sending email with multiple recipients.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - client.email.from_("sender@example.com").to( - "recipient1@example.com", "recipient2@example.com" - ).subject("Test").send() - - request = route.calls.last.request - import json - - body = json.loads(request.content) - assert body["to"] == ["recipient1@example.com", "recipient2@example.com"] - - @respx.mock - def test_send_with_html_and_text(self, api_token: str) -> None: - """Test sending email with HTML and text content.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Test" - ).html("

Hello

").text("Hello").send() - - import json - - body = json.loads(route.calls.last.request.content) - assert body["html"] == "

Hello

" - assert body["text"] == "Hello" - - @respx.mock - def test_send_with_cc_and_bcc(self, api_token: str) -> None: - """Test sending email with CC and BCC recipients.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - client.email.from_("sender@example.com").to("recipient@example.com").subject("Test").cc( - "cc1@example.com", "cc2@example.com" - ).bcc("bcc@example.com").send() - - import json - - body = json.loads(route.calls.last.request.content) - assert body["cc"] == ["cc1@example.com", "cc2@example.com"] - assert body["bcc"] == ["bcc@example.com"] - - @respx.mock - def test_send_with_reply_to(self, api_token: str) -> None: - """Test sending email with Reply-To addresses.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Test" - ).reply_to("reply@example.com").send() - - import json - - body = json.loads(route.calls.last.request.content) - assert body["reply_to"] == ["reply@example.com"] - - @respx.mock - def test_send_with_attachments(self, api_token: str) -> None: - """Test sending email with attachments.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Test" - ).attach("document.pdf", "base64content").attach( - "logo.png", "base64image", "logo@example.com", "image/png" - ).send() - - import json - - body = json.loads(route.calls.last.request.content) - assert len(body["attachments"]) == 2 - assert body["attachments"][0] == {"filename": "document.pdf", "content": "base64content"} - assert body["attachments"][1] == { - "filename": "logo.png", - "content": "base64image", - "content_id": "logo@example.com", - "content_type": "image/png", - } - - @respx.mock - def test_send_with_custom_headers(self, api_token: str) -> None: - """Test sending email with custom headers.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Test" - ).headers({"X-Custom-Header": "value"}).send() - - import json - - body = json.loads(route.calls.last.request.content) - assert body["headers"] == {"X-Custom-Header": "value"} - - @respx.mock - def test_send_with_idempotency_key(self, api_token: str) -> None: - """Test sending email with idempotency key.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Test" - ).idempotency_key("unique-key-123").send() - - request = route.calls.last.request - assert request.headers["Idempotency-Key"] == "unique-key-123" - - @respx.mock - def test_send_with_metadata_and_tag(self, api_token: str) -> None: - """Test sending email with metadata and tag.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Test" - ).scheduled_at("2026-10-01T09:00:00Z").metadata({"campaign_id": "123"}).tag( - "welcome" - ).tags([{"name": "campaign", "value": "welcome-v2"}]).settings( - {"track_opens": False, "track_clicks": True, "tls": "enforced"} - ).send() - - import json - - body = json.loads(route.calls.last.request.content) - assert body["metadata"] == {"campaign_id": "123"} - assert body["tag"] == "welcome" - assert body["scheduled_at"] == "2026-10-01T09:00:00Z" - assert body["tags"] == [{"name": "campaign", "value": "welcome-v2"}] - assert body["settings"] == { - "track_opens": False, - "track_clicks": True, - "tls": "enforced", - } - - def test_typed_message_tags_and_legacy_dictionary_support(self, api_token: str) -> None: - from lettermint import MessageTag - - with Lettermint(api_token=api_token) as client: - endpoint = client.email.tags( - [ - MessageTag(name="campaign", value="welcome"), - {"name": "customer", "value": "new"}, - ] - ) - assert endpoint._payload["tags"] == [ - {"name": "campaign", "value": "welcome"}, - {"name": "customer", "value": "new"}, - ] - - def test_rejects_invalid_message_tags(self, api_token: str) -> None: - with Lettermint(api_token=api_token) as client: - with pytest.raises(ValueError): - client.email.tags( - [{"name": "duplicate", "value": "one"}, {"name": "duplicate", "value": "two"}] - ) - with pytest.raises(ValueError): - client.email.tags([{"name": "__LETTERMINT_internal", "value": "one"}]) - with pytest.raises(ValueError): - client.email.tag("legacy").tags( - [{"name": f"tag_{index}", "value": "one"} for index in range(20)] - ) - - @respx.mock - def test_send_with_route(self, api_token: str) -> None: - """Test sending email with route.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Test" - ).route("my-route").send() - - import json - - body = json.loads(route.calls.last.request.content) - assert body["route"] == "my-route" - - @respx.mock - def test_send_with_sandbox_result(self, api_token: str) -> None: - """Test selecting a simulated Sandbox result.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response( - 200, - json={ - "message_id": "msg_123", - "status": "delivered", - "sandbox": True, - "sandbox_result": "clicked", - }, - ) - ) - - with Lettermint(api_token=api_token) as client: - response = ( - client.email.from_("sender@example.com") - .to("recipient@example.com") - .subject("Test") - .sandbox_result("clicked") - .send() - ) - - body = json.loads(route.calls.last.request.content) - assert body["sandbox_result"] == "clicked" - assert response["sandbox"] is True - assert response["sandbox_result"] == "clicked" - - @respx.mock - def test_validation_error(self, api_token: str) -> None: - """Test handling validation errors (422).""" - respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(422, json={"error": "DailyLimitExceeded"}) - ) - - with Lettermint(api_token=api_token) as client, pytest.raises(ValidationError) as exc_info: - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Test" - ).send() - - assert exc_info.value.status_code == 422 - assert exc_info.value.error_type == "DailyLimitExceeded" - - @respx.mock - def test_client_error(self, api_token: str) -> None: - """Test handling client errors (400).""" - respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(400, json={"error": "Invalid request"}) - ) - - with Lettermint(api_token=api_token) as client, pytest.raises(ClientError) as exc_info: - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Test" - ).send() - - assert exc_info.value.status_code == 400 - - @respx.mock - def test_rfc_5322_addresses(self, api_token: str) -> None: - """Test RFC 5322 format email addresses.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - client.email.from_("John Doe ").to( - "Jane Doe " - ).subject("Test").send() - - import json - - body = json.loads(route.calls.last.request.content) - assert body["from"] == "John Doe " - assert body["to"] == ["Jane Doe "] - - @respx.mock - def test_payload_reset_after_send(self, api_token: str) -> None: - """Test that payload is reset after sending.""" - respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - with Lettermint(api_token=api_token) as client: - # First send - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "First" - ).tag("first").send() - - # Second send should not include tag from first send - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_456", "status": "pending"}) - ) - - client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Second" - ).send() - - import json - - body = json.loads(route.calls.last.request.content) - assert "tag" not in body - - -class TestEmailEndpointAsync: - """Tests for the asynchronous email endpoint.""" - - @respx.mock - @pytest.mark.asyncio - async def test_send_basic_email_async(self, api_token: str) -> None: - """Test sending a basic email asynchronously.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response( - 200, - json={"message_id": "msg_123", "status": "pending"}, - ) - ) - - async with AsyncLettermint(api_token=api_token) as client: - response = await ( - client.email.from_("sender@example.com") - .to("recipient@example.com") - .subject("Test Subject") - .send() - ) - - assert response["message_id"] == "msg_123" - assert response["status"] == "pending" - assert route.called - - @respx.mock - @pytest.mark.asyncio - async def test_send_with_all_options_async(self, api_token: str) -> None: - """Test sending email with all options asynchronously.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - return_value=Response(200, json={"message_id": "msg_123", "status": "pending"}) - ) - - async with AsyncLettermint(api_token=api_token) as client: - await ( - client.email.from_("sender@example.com") - .to("recipient@example.com") - .subject("Test") - .html("

Hello

") - .text("Hello") - .cc("cc@example.com") - .bcc("bcc@example.com") - .reply_to("reply@example.com") - .headers({"X-Custom": "value"}) - .attach("file.pdf", "base64content") - .metadata({"key": "value"}) - .tag("campaign") - .route("my-route") - .sandbox_result("opened") - .idempotency_key("unique-key") - .send() - ) - - import json - - body = json.loads(route.calls.last.request.content) - assert body["from"] == "sender@example.com" - assert body["to"] == ["recipient@example.com"] - assert body["subject"] == "Test" - assert body["html"] == "

Hello

" - assert body["text"] == "Hello" - assert body["cc"] == ["cc@example.com"] - assert body["bcc"] == ["bcc@example.com"] - assert body["reply_to"] == ["reply@example.com"] - assert body["headers"] == {"X-Custom": "value"} - assert body["attachments"] == [{"filename": "file.pdf", "content": "base64content"}] - assert body["metadata"] == {"key": "value"} - assert body["tag"] == "campaign" - assert body["route"] == "my-route" - assert body["sandbox_result"] == "opened" - - request = route.calls.last.request - assert request.headers["Idempotency-Key"] == "unique-key" - - @respx.mock - @pytest.mark.asyncio - async def test_send_batch_with_idempotency_key_async(self, api_token: str) -> None: - """Test an asynchronous batch idempotency key.""" - route = respx.post("https://api.lettermint.co/v1/send/batch").mock( - return_value=Response(200, json=[{"message_id": "msg_123", "status": "pending"}]) - ) - payload = [ - { - "from": "sender@example.com", - "to": ["recipient@example.com"], - "subject": "Test", - } - ] - - async with AsyncLettermint(api_token=api_token) as client: - await client.email.idempotency_key("batch-key").send_batch(payload) - - assert route.calls.last.request.headers["Idempotency-Key"] == "batch-key" - - @respx.mock - @pytest.mark.asyncio - async def test_deferred_async_sends_use_payload_snapshots(self, api_token: str) -> None: - """Deferred async send coroutines must not share later builder mutations.""" - route = respx.post("https://api.lettermint.co/v1/send").mock( - side_effect=[ - Response(200, json={"message_id": "msg_1", "status": "pending"}), - Response(200, json={"message_id": "msg_2", "status": "pending"}), - ] - ) - - async with AsyncLettermint(api_token=api_token) as client: - first = ( - client.email.from_("sender@example.com") - .to("first@example.com") - .subject("First") - .attach("secret.pdf", "base64secret") - .metadata({"invoice": "123"}) - .idempotency_key("first-key") - .send() - ) - second = ( - client.email.from_("sender@example.com") - .to("second@example.com") - .subject("Second") - .send() - ) - - await asyncio.gather(first, second) - - first_request = route.calls[0].request - second_request = route.calls[1].request - first_body = json.loads(first_request.content) - second_body = json.loads(second_request.content) - - assert first_body["to"] == ["first@example.com"] - assert first_body["attachments"] == [{"filename": "secret.pdf", "content": "base64secret"}] - assert first_body["metadata"] == {"invoice": "123"} - assert first_request.headers["Idempotency-Key"] == "first-key" - - assert second_body["to"] == ["second@example.com"] - assert "attachments" not in second_body - assert "metadata" not in second_body - assert "Idempotency-Key" not in second_request.headers diff --git a/tests/test_packaging.py b/tests/test_packaging.py deleted file mode 100644 index 8743cf2..0000000 --- a/tests/test_packaging.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Packaging checks for the declared runtime dependencies.""" - -import re -from importlib.metadata import requires -from pathlib import Path - -import lettermint - - -def test_typing_extensions_declared_for_all_python_versions() -> None: - """Modules import typing_extensions unconditionally, so it must be an unconditional dependency.""" - package_dir = Path(lettermint.__file__).parent - imports_typing_extensions = any( - re.search(r"^\s*(from|import) typing_extensions\b", path.read_text(), re.MULTILINE) - for path in package_dir.rglob("*.py") - ) - assert imports_typing_extensions - - declared = requires("lettermint") or [] - unconditional = [ - requirement - for requirement in declared - if re.match(r"typing[-_]extensions\b", requirement, re.IGNORECASE) - and "extra ==" not in requirement - and ";" not in requirement - ] - assert unconditional, f"typing_extensions must be an unconditional dependency, got {declared}" diff --git a/tests/test_route_contract.py b/tests/test_route_contract.py deleted file mode 100644 index 56694b8..0000000 --- a/tests/test_route_contract.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Check the optional, nullable inbound route domain.""" - -from typing import Any - -import pytest -import respx -from httpx import Response - -from lettermint import Lettermint -from lettermint.types import RouteData - - -@pytest.mark.parametrize("domain", ["incoming.example.com", None, ...]) -@respx.mock -def test_inbound_route_domain(domain: Any) -> None: - payload: RouteData = { - "id": "route_1", - "project_id": "project_1", - "slug": "incoming", - "name": "Incoming", - "route_type": "inbound", - "is_default": False, - "created_at": "2026-10-01T12:00:00Z", - "updated_at": "2026-10-01T12:00:00Z", - } - if domain is not ...: - payload["inbound_route_domain"] = domain - respx.get("https://api.lettermint.co/v1/routes/route_1").mock( - return_value=Response(200, json=payload) - ) - with Lettermint.api("team-token") as api: - result = api.routes.retrieve("route_1") - assert result == payload - assert result.get("inbound_route_domain") == (None if domain is ... else domain) - - -def test_inbound_route_domain_is_declared() -> None: - assert "inbound_route_domain" in RouteData.__annotations__ - assert "NotRequired[str | None]" in str(RouteData.__annotations__["inbound_route_domain"]) diff --git a/tests/test_webhook.py b/tests/test_webhook.py deleted file mode 100644 index 07bd63a..0000000 --- a/tests/test_webhook.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Tests for webhook verification.""" - -import hashlib -import hmac -import json -import time -from typing import Optional - -import pytest - -from lettermint import Webhook -from lettermint.exceptions import ( - InvalidSignatureError, - JsonDecodeError, - TimestampToleranceError, - WebhookVerificationError, -) - - -def generate_valid_signature( - payload: str, secret: str, timestamp: Optional[int] = None -) -> tuple[str, int]: - """Generate a valid signature for testing.""" - ts = timestamp or int(time.time()) - signed_content = f"{ts}.{payload}" - signature = hmac.new( - secret.encode(), - signed_content.encode(), - hashlib.sha256, - ).hexdigest() - return f"t={ts},v1={signature}", ts - - -class TestWebhook: - """Tests for the Webhook class.""" - - def test_verify_valid_signature(self, webhook_secret: str) -> None: - """Test verifying a valid webhook signature.""" - payload = json.dumps({"event": "email.delivered", "data": {"message_id": "123"}}) - signature, timestamp = generate_valid_signature(payload, webhook_secret) - - webhook = Webhook(secret=webhook_secret) - result = webhook.verify(payload, signature) - - assert result["event"] == "email.delivered" - assert result["data"]["message_id"] == "123" - - def test_verify_with_timestamp_validation(self, webhook_secret: str) -> None: - """Test verifying signature with cross-validated timestamp.""" - payload = json.dumps({"event": "email.delivered"}) - signature, timestamp = generate_valid_signature(payload, webhook_secret) - - webhook = Webhook(secret=webhook_secret) - result = webhook.verify(payload, signature, timestamp) - - assert result["event"] == "email.delivered" - - def test_verify_headers(self, webhook_secret: str) -> None: - """Test verifying webhook using headers.""" - payload = json.dumps({"event": "email.delivered"}) - signature, timestamp = generate_valid_signature(payload, webhook_secret) - - headers = { - "X-Lettermint-Signature": signature, - "X-Lettermint-Delivery": str(timestamp), - } - - webhook = Webhook(secret=webhook_secret) - result = webhook.verify_headers(headers, payload) - - assert result["event"] == "email.delivered" - - def test_verify_headers_case_insensitive(self, webhook_secret: str) -> None: - """Test that header names are case-insensitive.""" - payload = json.dumps({"event": "email.delivered"}) - signature, timestamp = generate_valid_signature(payload, webhook_secret) - - headers = { - "x-lettermint-signature": signature, - "x-lettermint-delivery": str(timestamp), - } - - webhook = Webhook(secret=webhook_secret) - result = webhook.verify_headers(headers, payload) - - assert result["event"] == "email.delivered" - - def test_static_verify_signature(self, webhook_secret: str) -> None: - """Test static convenience method.""" - payload = json.dumps({"event": "email.delivered"}) - signature, _ = generate_valid_signature(payload, webhook_secret) - - result = Webhook.verify_signature(payload, signature, webhook_secret) - - assert result["event"] == "email.delivered" - - def test_invalid_signature(self, webhook_secret: str) -> None: - """Test that invalid signatures are rejected.""" - payload = json.dumps({"event": "email.delivered"}) - timestamp = int(time.time()) - invalid_signature = f"t={timestamp},v1=invalidsignaturehash" - - webhook = Webhook(secret=webhook_secret) - with pytest.raises(InvalidSignatureError, match="Signature verification failed"): - webhook.verify(payload, invalid_signature) - - def test_tampered_payload(self, webhook_secret: str) -> None: - """Test that tampered payloads are rejected.""" - original_payload = json.dumps({"event": "email.delivered"}) - signature, _ = generate_valid_signature(original_payload, webhook_secret) - - tampered_payload = json.dumps({"event": "email.bounced"}) - - webhook = Webhook(secret=webhook_secret) - with pytest.raises(InvalidSignatureError): - webhook.verify(tampered_payload, signature) - - def test_wrong_secret(self, webhook_secret: str) -> None: - """Test that wrong secrets are rejected.""" - payload = json.dumps({"event": "email.delivered"}) - signature, _ = generate_valid_signature(payload, webhook_secret) - - webhook = Webhook(secret="wrong-secret") - with pytest.raises(InvalidSignatureError): - webhook.verify(payload, signature) - - def test_timestamp_too_old(self, webhook_secret: str) -> None: - """Test that old timestamps are rejected.""" - payload = json.dumps({"event": "email.delivered"}) - old_timestamp = int(time.time()) - 600 # 10 minutes ago - signature, _ = generate_valid_signature(payload, webhook_secret, old_timestamp) - - webhook = Webhook(secret=webhook_secret, tolerance=300) - with pytest.raises(TimestampToleranceError, match="Timestamp outside tolerance"): - webhook.verify(payload, signature) - - def test_timestamp_in_future(self, webhook_secret: str) -> None: - """Test that future timestamps are rejected.""" - payload = json.dumps({"event": "email.delivered"}) - future_timestamp = int(time.time()) + 600 # 10 minutes in future - signature, _ = generate_valid_signature(payload, webhook_secret, future_timestamp) - - webhook = Webhook(secret=webhook_secret, tolerance=300) - with pytest.raises(TimestampToleranceError): - webhook.verify(payload, signature) - - def test_custom_tolerance(self, webhook_secret: str) -> None: - """Test custom timestamp tolerance.""" - payload = json.dumps({"event": "email.delivered"}) - old_timestamp = int(time.time()) - 400 # 6.67 minutes ago - signature, _ = generate_valid_signature(payload, webhook_secret, old_timestamp) - - # Default tolerance (300s) should reject - webhook_default = Webhook(secret=webhook_secret) - with pytest.raises(TimestampToleranceError): - webhook_default.verify(payload, signature) - - # Custom tolerance (600s) should accept - webhook_custom = Webhook(secret=webhook_secret, tolerance=600) - result = webhook_custom.verify(payload, signature) - assert result["event"] == "email.delivered" - - def test_invalid_signature_format(self, webhook_secret: str) -> None: - """Test that invalid signature format is rejected.""" - payload = json.dumps({"event": "email.delivered"}) - - webhook = Webhook(secret=webhook_secret) - - # Missing timestamp - with pytest.raises(WebhookVerificationError, match="Invalid signature format"): - webhook.verify(payload, "v1=somehash") - - # Missing signature hash - with pytest.raises(WebhookVerificationError, match="Invalid signature format"): - webhook.verify(payload, "t=12345") - - # Completely invalid - with pytest.raises(WebhookVerificationError, match="Invalid signature format"): - webhook.verify(payload, "garbage") - - def test_timestamp_mismatch(self, webhook_secret: str) -> None: - """Test timestamp mismatch between signature and delivery header.""" - payload = json.dumps({"event": "email.delivered"}) - signature, timestamp = generate_valid_signature(payload, webhook_secret) - - webhook = Webhook(secret=webhook_secret) - with pytest.raises(WebhookVerificationError, match="Timestamp mismatch"): - webhook.verify(payload, signature, timestamp + 1) - - def test_missing_signature_header(self, webhook_secret: str) -> None: - """Test missing signature header.""" - payload = json.dumps({"event": "email.delivered"}) - - headers = { - "X-Lettermint-Delivery": "12345", - } - - webhook = Webhook(secret=webhook_secret) - with pytest.raises(WebhookVerificationError, match="Missing signature header"): - webhook.verify_headers(headers, payload) - - def test_missing_delivery_header(self, webhook_secret: str) -> None: - """Test missing delivery header.""" - payload = json.dumps({"event": "email.delivered"}) - signature, _ = generate_valid_signature(payload, webhook_secret) - - headers = { - "X-Lettermint-Signature": signature, - } - - webhook = Webhook(secret=webhook_secret) - with pytest.raises(WebhookVerificationError, match="Missing delivery header"): - webhook.verify_headers(headers, payload) - - def test_invalid_json_payload(self, webhook_secret: str) -> None: - """Test invalid JSON payload.""" - payload = "not valid json {" - timestamp = int(time.time()) - signed_content = f"{timestamp}.{payload}" - signature = hmac.new( - webhook_secret.encode(), - signed_content.encode(), - hashlib.sha256, - ).hexdigest() - signature_header = f"t={timestamp},v1={signature}" - - webhook = Webhook(secret=webhook_secret) - with pytest.raises(JsonDecodeError, match="Failed to decode webhook payload"): - webhook.verify(payload, signature_header) - - def test_empty_secret_raises_error(self) -> None: - """Test that empty secret raises ValueError.""" - with pytest.raises(ValueError, match="Webhook secret cannot be empty"): - Webhook(secret="") - - def test_complex_payload(self, webhook_secret: str) -> None: - """Test verification with complex nested payload.""" - payload_data = { - "event": "email.delivered", - "data": { - "message_id": "msg_123", - "recipient": "user@example.com", - "metadata": {"campaign_id": "456", "user_id": "789"}, - "timestamps": {"sent_at": 1700000000, "delivered_at": 1700000010}, - }, - } - payload = json.dumps(payload_data) - signature, _ = generate_valid_signature(payload, webhook_secret) - - webhook = Webhook(secret=webhook_secret) - result = webhook.verify(payload, signature) - - assert result == payload_data - - -class TestWebhookBytesPayload: - """Tests for raw bytes payloads (e.g. Django's request.body).""" - - def test_verify_bytes_payload(self, webhook_secret: str) -> None: - """Bytes payloads are verified against the raw body.""" - payload = json.dumps({"event": "email.delivered", "data": {"name": "café"}}) - signature, _ = generate_valid_signature(payload, webhook_secret) - - webhook = Webhook(secret=webhook_secret) - result = webhook.verify(payload.encode(), signature) - - assert result["event"] == "email.delivered" - assert result["data"]["name"] == "café" - - def test_verify_bytes_matches_str(self, webhook_secret: str) -> None: - """Str payloads keep working and match the bytes result.""" - payload = json.dumps({"event": "email.delivered"}) - signature, _ = generate_valid_signature(payload, webhook_secret) - - webhook = Webhook(secret=webhook_secret) - assert webhook.verify(payload, signature) == webhook.verify(payload.encode(), signature) - - def test_verify_bytes_uses_raw_body_byte_for_byte(self, webhook_secret: str) -> None: - """Whitespace and non-UTF-8-normalised bodies are signed exactly as received.""" - payload = b'{ "event" : "email.delivered" }\n' - timestamp = int(time.time()) - digest = hmac.new( - webhook_secret.encode(), f"{timestamp}.".encode() + payload, hashlib.sha256 - ).hexdigest() - - webhook = Webhook(secret=webhook_secret) - result = webhook.verify(payload, f"t={timestamp},v1={digest}") - - assert result["event"] == "email.delivered" - - def test_tampered_bytes_payload(self, webhook_secret: str) -> None: - """Tampered bytes payloads are rejected.""" - payload = json.dumps({"event": "email.delivered"}) - signature, _ = generate_valid_signature(payload, webhook_secret) - - webhook = Webhook(secret=webhook_secret) - with pytest.raises(InvalidSignatureError): - webhook.verify(json.dumps({"event": "email.bounced"}).encode(), signature) - - def test_verify_headers_bytes_payload(self, webhook_secret: str) -> None: - """verify_headers accepts a bytes payload.""" - payload = json.dumps({"event": "email.delivered"}) - signature, timestamp = generate_valid_signature(payload, webhook_secret) - headers = { - "X-Lettermint-Signature": signature, - "X-Lettermint-Delivery": str(timestamp), - } - - webhook = Webhook(secret=webhook_secret) - assert webhook.verify_headers(headers, payload.encode())["event"] == "email.delivered" - - def test_static_verify_signature_bytes_payload(self, webhook_secret: str) -> None: - """The static helper accepts a bytes payload.""" - payload = json.dumps({"event": "email.delivered"}) - signature, _ = generate_valid_signature(payload, webhook_secret) - - result = Webhook.verify_signature(payload.encode(), signature, webhook_secret) - assert result["event"] == "email.delivered" - - def test_invalid_utf8_bytes_payload_raises_json_error(self, webhook_secret: str) -> None: - """A correctly signed but non-decodable body raises JsonDecodeError.""" - payload = b"\xff\xfe not json" - timestamp = int(time.time()) - digest = hmac.new( - webhook_secret.encode(), f"{timestamp}.".encode() + payload, hashlib.sha256 - ).hexdigest() - - webhook = Webhook(secret=webhook_secret) - with pytest.raises(JsonDecodeError): - webhook.verify(payload, f"t={timestamp},v1={digest}") - - def test_non_ascii_signature_raises_verification_error(self, webhook_secret: str) -> None: - """Non-ASCII signature values fail verification instead of raising TypeError.""" - payload = json.dumps({"event": "email.delivered"}) - timestamp = int(time.time()) - - webhook = Webhook(secret=webhook_secret) - with pytest.raises(InvalidSignatureError): - webhook.verify(payload, f"t={timestamp},v1=café") diff --git a/tests/test_webhook_basic_auth.py b/tests/test_webhook_basic_auth.py deleted file mode 100644 index 3d4c779..0000000 --- a/tests/test_webhook_basic_auth.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import sys -from pathlib import Path -from typing import get_args, get_origin, get_type_hints - -import pytest -import respx -from httpx import Response -from typing_extensions import NotRequired, Required - -from lettermint import AsyncLettermint, HttpRequestError, Lettermint -from lettermint import types as lm_types - - -@pytest.mark.parametrize( - "state", - [{}, {"basic_auth": {"username": " fixture user ", "password": ""}}, {"basic_auth": None}], -) -@pytest.mark.parametrize("asynchronous", [False, True]) -@respx.mock -@pytest.mark.asyncio -async def test_webhook_credential_states_keep_bearer_auth(state: dict, asynchronous: bool) -> None: - create = respx.post("https://api.lettermint.co/v1/webhooks").mock( - return_value=Response(201, json={"data": {"has_basic_auth": True}}) - ) - update = respx.put("https://api.lettermint.co/v1/webhooks/webhook-id").mock( - return_value=Response(200, json={"data": {"has_basic_auth": True}}) - ) - payload = { - "name": "Fixture", - "url": "https://example.test/hook", - "events": ["message.sent"], - **state, - } - if asynchronous: - async with AsyncLettermint.api("fixture-token") as api: - assert (await api.webhooks.create(payload))["data"]["has_basic_auth"] is True - assert (await api.webhooks.update("webhook-id", state))["data"][ - "has_basic_auth" - ] is True - else: - with Lettermint.api("fixture-token") as sync_api: - assert sync_api.webhooks.create(payload)["data"]["has_basic_auth"] is True - assert sync_api.webhooks.update("webhook-id", state)["data"]["has_basic_auth"] is True - for route, expected in [(create, payload), (update, state)]: - request = route.calls.last.request - assert json.loads(request.content) == expected - assert request.headers["authorization"] == "Bearer fixture-token" - assert "x-lettermint-token" not in request.headers - - -def test_webhook_types_expose_required_read_flag_and_optional_nullable_credentials() -> None: - def field_hint(model: type, field: str): - selected = type( - "SelectedField", (), {"__annotations__": {field: model.__annotations__[field]}} - ) - return get_type_hints(selected, globalns=vars(lm_types), include_extras=True)[field] - - for model in [lm_types.WebhookData, lm_types.WebhookListData, lm_types.WebhookSecretData]: - hint = field_hint(model, "has_basic_auth") - assert get_origin(hint) is Required and get_args(hint) == (bool,) - for model in [lm_types.StoreWebhookData, lm_types.UpdateWebhookData]: - hint = field_hint(model, "basic_auth") - assert get_origin(hint) is NotRequired - assert set(get_args(get_args(hint)[0])) == {lm_types.WebhookBasicAuthData, type(None)} - assert "has_basic_auth" not in model.__annotations__ - credentials: lm_types.WebhookBasicAuthData = {"username": "fixture", "password": ""} - assert credentials["password"] == "" - - -@respx.mock -def test_free_plan_sandbox_keeps_403_response() -> None: - body = { - "error": { - "code": "FEATURE_NOT_AVAILABLE", - "message": "Sandbox mode is available only on paid plans.", - } - } - respx.post("https://api.lettermint.co/v1/send").mock(return_value=Response(403, json=body)) - with Lettermint.email("fixture-token") as email, pytest.raises(HttpRequestError) as caught: - email.from_("from@example.test").to("to@example.test").subject("Fixture").send() - assert caught.value.status_code == 403 - assert caught.value.response_body == body - - -@pytest.mark.parametrize("model", ["WebhookData", "WebhookListData", "WebhookSecretData"]) -def test_old_typed_webhook_fixtures_need_safe_read_flag(tmp_path: Path, model: str) -> None: - value = { - "id": "fixture", - "scope": "route", - "project_ids": [], - "route_ids": [], - "route_id": None, - "name": "Fixture", - "url": "https://example.test/hook", - "events": [], - "enabled": True, - "last_called_at": None, - "created_at": "", - "updated_at": "", - "delivery_mode_filter": "both", - } - if model != "WebhookListData": - value["include_machine_events"] = False - if model == "WebhookSecretData": - value["secret"] = "synthetic-signing-secret" - caller = tmp_path / "old_caller.py" - environment = {**os.environ, "MYPYPATH": str(Path(__file__).resolve().parents[1] / "src")} - command = [ - sys.executable, - "-m", - "mypy", - "--python-version", - "3.10", - "--follow-imports=silent", - "--no-incremental", - "--cache-dir", - str(tmp_path / "mypy-cache"), - str(caller), - ] - caller.write_text(f"from lettermint.types import {model}\nfixture: {model} = {value!r}\n") - old = subprocess.run(command, env=environment, capture_output=True, text=True, check=False) - assert ( - old.returncode == 1 - and f'Missing key "has_basic_auth" for TypedDict "{model}"' in old.stdout - ) - assert "Found 1 error" in old.stdout - value["has_basic_auth"] = False - caller.write_text(f"from lettermint.types import {model}\nfixture: {model} = {value!r}\n") - migrated = subprocess.run(command, env=environment, capture_output=True, text=True, check=False) - assert migrated.returncode == 0, migrated.stdout + migrated.stderr From 6a56a4221d421ae434b0ebbf9c4f635665a30fba Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sat, 3 Oct 2026 23:59:21 +0200 Subject: [PATCH 4/8] feat!: rewrite the SDK for 3.0 with stateless sending and per-surface tokens One client per mode, Lettermint and AsyncLettermint, with identical surfaces: sending_token for emails.*, team_token for every other part, no fallback between them, and a token string classified by its prefix. Sending stores no message state: emails.send/send_batch take the message and a per-call idempotency_key, and emails.compose() returns an immutable builder. The async layer is written once; scripts/unasync.py generates the sync layer from it, and the I/O-free core (request preparation, decoding, error mapping, query serialization, tag validation) is shared. Requests never follow redirects or retry, the timeout covers the whole request including the body, and every httpx exception is translated into an SDK exception with no chained request. Tokens never appear in repr, vars or pickles. Typed exceptions (APIError and subclasses, APITimeoutError, APIConnectionError, UnexpectedResponseError, RedirectError, LettermintConfigError, LettermintValidationError), typed nested query objects, iterate() generators that follow next_cursor, and a Webhook verifier that requires both signature headers, accepts any v1 and reports a reason. BREAKING CHANGE: Lettermint.email()/api(), ApiClient, AsyncApiClient, the endpoint classes, MessageTag, the 2.x exceptions and type names are removed; Python 3.10 or newer is required. See UPGRADE.md. --- pyproject.toml | 46 +- scripts/unasync.py | 104 ++++ src/lettermint/__init__.py | 187 +++--- src/lettermint/_async/__init__.py | 36 ++ src/lettermint/_async/client.py | 154 +++++ src/lettermint/_async/emails.py | 101 +++ src/lettermint/_async/resources.py | 955 ++++++++++++++++++++++++++++ src/lettermint/_core.py | 414 +++++++++++++ src/lettermint/_emails.py | 350 +++++++++++ src/lettermint/_query.py | 83 +++ src/lettermint/_sync/__init__.py | 37 ++ src/lettermint/_sync/client.py | 155 +++++ src/lettermint/_sync/emails.py | 102 +++ src/lettermint/_sync/resources.py | 956 +++++++++++++++++++++++++++++ src/lettermint/_transport.py | 213 +++++++ src/lettermint/exceptions.py | 224 +++++-- src/lettermint/types.py | 10 + src/lettermint/webhook.py | 410 ++++++------- tests/__init__.py | 0 tests/conftest.py | 98 +++ tests/test_client.py | 221 +++++++ tests/test_emails.py | 332 ++++++++++ tests/test_query.py | 95 +++ tests/test_surface.py | 379 ++++++++++++ tests/test_tooling.py | 61 ++ tests/test_transport.py | 572 +++++++++++++++++ tests/test_webhook.py | 259 ++++++++ 27 files changed, 6222 insertions(+), 332 deletions(-) create mode 100644 scripts/unasync.py create mode 100644 src/lettermint/_async/__init__.py create mode 100644 src/lettermint/_async/client.py create mode 100644 src/lettermint/_async/emails.py create mode 100644 src/lettermint/_async/resources.py create mode 100644 src/lettermint/_core.py create mode 100644 src/lettermint/_emails.py create mode 100644 src/lettermint/_query.py create mode 100644 src/lettermint/_sync/__init__.py create mode 100644 src/lettermint/_sync/client.py create mode 100644 src/lettermint/_sync/emails.py create mode 100644 src/lettermint/_sync/resources.py create mode 100644 src/lettermint/_transport.py create mode 100644 src/lettermint/types.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_client.py create mode 100644 tests/test_emails.py create mode 100644 tests/test_query.py create mode 100644 tests/test_surface.py create mode 100644 tests/test_tooling.py create mode 100644 tests/test_transport.py create mode 100644 tests/test_webhook.py diff --git a/pyproject.toml b/pyproject.toml index 3fabba4..e2b707a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,38 +4,41 @@ build-backend = "hatchling.build" [project] name = "lettermint" -version = "1.0.0" +dynamic = ["version"] description = "Official Lettermint Python SDK" readme = "README.md" license = "MIT" -requires-python = ">=3.9" +requires-python = ">=3.10" authors = [ { name = "Bjarn Bronsveld", email = "bjarn@lettermint.co" } ] -keywords = ["lettermint", "email", "sdk", "api"] +keywords = ["lettermint", "email", "sdk", "api", "transactional email", "webhooks"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", + "Framework :: AnyIO", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Communications :: Email", "Typing :: Typed", ] dependencies = [ - "httpx>=0.27", - "typing_extensions>=4.0", + "httpx>=0.27,<1", + "anyio>=3.5", + "typing_extensions>=4.7", ] [project.optional-dependencies] dev = [ "pytest>=8.0", - "pytest-asyncio>=0.24", - "respx>=0.21", + "anyio[trio]>=3.5", "mypy>=1.11", "ruff>=0.6", ] @@ -45,24 +48,37 @@ Homepage = "https://github.com/lettermint/lettermint-python" Documentation = "https://github.com/lettermint/lettermint-python#readme" Repository = "https://github.com/lettermint/lettermint-python" Issues = "https://github.com/lettermint/lettermint-python/issues" +Changelog = "https://github.com/lettermint/lettermint-python/blob/main/CHANGELOG.md" +Upgrade = "https://github.com/lettermint/lettermint-python/blob/main/UPGRADE.md" + +[tool.hatch.version] +path = "src/lettermint/_version.py" [tool.hatch.build.targets.wheel] packages = ["src/lettermint"] +[tool.hatch.build.targets.wheel.force-include] +"UPGRADE.md" = "lettermint/UPGRADE.md" + +[tool.hatch.build.targets.sdist] +include = ["src/lettermint", "tests", "scripts", "README.md", "UPGRADE.md", "CHANGELOG.md", "LICENSE"] + [tool.pytest.ini_options] testpaths = ["tests"] -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" +filterwarnings = ["error"] [tool.mypy] -python_version = "3.9" +python_version = "3.10" strict = true warn_return_any = true warn_unused_ignores = true +files = ["src/lettermint", "tests", "scripts"] [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 100 +# Written by the SDK generator and by scripts/unasync.py; checked by their own tools. +extend-exclude = ["src/lettermint/_generated", "src/lettermint/_sync"] [tool.ruff.lint] select = [ @@ -75,13 +91,15 @@ select = [ "UP", # pyupgrade "ARG", # flake8-unused-arguments "SIM", # flake8-simplify + "A", # flake8-builtins ] ignore = [ "E501", # line too long (handled by formatter) + "A003", # resource methods are named list, delete, ... like the API ] [tool.ruff.lint.isort] known-first-party = ["lettermint"] [tool.ruff.lint.per-file-ignores] -"src/lettermint/types.py" = ["UP013"] +"tests/**" = ["ARG"] diff --git a/scripts/unasync.py b/scripts/unasync.py new file mode 100644 index 0000000..6a7ff22 --- /dev/null +++ b/scripts/unasync.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Generate the synchronous client (``lettermint._sync``) from the asynchronous one. + +The SDK has one hand-written implementation of the client, the sub-clients +and the email builder: ``src/lettermint/_async``. This script rewrites it +token by token into ``src/lettermint/_sync``: ``async def`` becomes ``def``, +``await`` disappears, ``AsyncFoo`` becomes ``Foo``, ``AsyncIterator`` becomes +``Iterator``, ``__aenter__`` becomes ``__enter__``, and so on. Everything +that differs for real between the two (sending requests, the deadline) lives +in ``_transport.py``, which is written by hand for both. + + python scripts/unasync.py # rewrite src/lettermint/_sync + python scripts/unasync.py --check # fail when src/lettermint/_sync is stale +""" + +from __future__ import annotations + +import argparse +import difflib +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "src" / "lettermint" / "_async" +TARGET = ROOT / "src" / "lettermint" / "_sync" + +RULES: list[tuple[re.Pattern[str], str]] = [ + (re.compile(pattern), replacement) + for pattern, replacement in ( + ( + r"The asynchronous client\. ``scripts/unasync\.py`` generates ``lettermint\._sync`` from it\.", + "The synchronous client, generated by ``scripts/unasync.py``.", + ), + (r"\basync def\b", "def"), + (r"\basync for\b", "for"), + (r"\basync with\b", "with"), + (r"\bawait ", ""), + (r"\b__aenter__\b", "__enter__"), + (r"\b__aexit__\b", "__exit__"), + (r"\baclose\b", "close"), + (r"\bAsync([A-Z]\w*)", r"\1"), + (r"\b_async\b", "_sync"), + (r"\basynchronous\b", "synchronous"), + ) +] + + +def header(name: str) -> str: + return f"# Generated from src/lettermint/_async/{name} by scripts/unasync.py — do not edit.\n" + + +def convert(text: str) -> str: + lines = [] + for line in text.splitlines(keepends=True): + for pattern, replacement in RULES: + line = pattern.sub(replacement, line) + lines.append(line) + return "".join(lines) + + +def outputs() -> dict[Path, str]: + return { + TARGET / path.name: header(path.name) + convert(path.read_text()) + for path in sorted(SOURCE.glob("*.py")) + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) + parser.add_argument( + "--check", action="store_true", help="do not write; fail when the files differ" + ) + args = parser.parse_args(argv) + files = outputs() + stale = sorted( + {path for path, text in files.items() if not path.exists() or path.read_text() != text} + | {path for path in TARGET.glob("*.py") if path not in files} + ) + if args.check: + for path in stale: + current = path.read_text().splitlines() if path.exists() else [] + wanted = files.get(path, "").splitlines() + diff = difflib.unified_diff( + current, wanted, f"{path} (committed)", f"{path} (generated)", lineterm="", n=1 + ) + print("\n".join(list(diff)[:40]), file=sys.stderr) + if stale: + print("src/lettermint/_sync is stale; run python scripts/unasync.py", file=sys.stderr) + return 1 + print("src/lettermint/_sync is up to date") + return 0 + TARGET.mkdir(exist_ok=True) + for path in TARGET.glob("*.py"): + if path not in files: + path.unlink() + for path, text in files.items(): + path.write_text(text) + print(f"wrote {len(files)} files to {TARGET.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/lettermint/__init__.py b/src/lettermint/__init__.py index e8bb6e5..1201f91 100644 --- a/src/lettermint/__init__.py +++ b/src/lettermint/__init__.py @@ -1,91 +1,132 @@ -""" -Lettermint Python SDK -===================== +"""The official Python SDK for Lettermint. -Official Python SDK for the Lettermint email API. +:: -Basic Usage: - >>> from lettermint import Lettermint - >>> - >>> client = Lettermint(api_token="your-api-token") - >>> - >>> response = ( - ... client.email - ... .from_("sender@example.com") - ... .to("recipient@example.com") - ... .subject("Hello from Python!") - ... .html("

Welcome!

") - ... .send() - ... ) - >>> print(response["message_id"]) + from lettermint import Lettermint -Async Usage: - >>> from lettermint import AsyncLettermint - >>> - >>> async with AsyncLettermint(api_token="your-api-token") as client: - ... response = await ( - ... client.email - ... .from_("sender@example.com") - ... .to("recipient@example.com") - ... .subject("Hello from Python!") - ... .html("

Welcome!

") - ... .send() - ... ) + lettermint = Lettermint(sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"]) + result = lettermint.emails.send({ + "from": "Acme ", + "to": ["jane@example.com"], + "subject": "Welcome to Acme", + "html": "

Thanks for signing up.

", + }) -Webhook Verification: - >>> from lettermint import Webhook - >>> - >>> webhook = Webhook(secret="your-webhook-secret") - >>> payload = webhook.verify_headers(request.headers, request.body) +``AsyncLettermint`` has the same surface for ``asyncio`` and ``trio``. Request +and response types are in :mod:`lettermint.types`. Upgrading from 2.x? Read +``UPGRADE.md``. """ +from . import types +from ._async import ( + AsyncDomains, + AsyncEmailBuilder, + AsyncEmails, + AsyncLettermint, + AsyncMessages, + AsyncProjects, + AsyncReportForwarding, + AsyncRoutes, + AsyncStats, + AsyncSuppressions, + AsyncTeam, + AsyncTeamMembers, + AsyncWebhookDeliveries, + AsyncWebhooks, +) +from ._emails import BaseEmailBuilder, EmailAttachment, EmailMessage +from ._generated.types import CursorPage +from ._sync import ( + Domains, + EmailBuilder, + Emails, + Lettermint, + Messages, + Projects, + ReportForwarding, + Routes, + Stats, + Suppressions, + Team, + TeamMembers, + WebhookDeliveries, + Webhooks, +) +from ._version import __version__ from .exceptions import ( - ClientError, - HttpRequestError, - InvalidSignatureError, - JsonDecodeError, + APIConnectionError, + APIError, + APITimeoutError, + AuthenticationError, + ConflictError, + LettermintConfigError, LettermintError, - TimeoutError, - TimestampToleranceError, + LettermintValidationError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + RedirectError, + ServerError, + UnexpectedResponseError, ValidationError, WebhookVerificationError, + WebhookVerificationReason, ) -from .lettermint import ApiClient, AsyncApiClient, AsyncLettermint, Lettermint -from .message_tag import MessageTag -from .types import ( - EmailAttachment, - EmailPayload, - EmailStatus, - SendBatchEmailResponse, - SendEmailResponse, -) -from .webhook import Webhook - -__version__ = "1.0.0" +from .webhook import Webhook, WebhookHeaders, WebhookPayload __all__ = [ - # Main clients - "Lettermint", + "APIConnectionError", + "APIError", + "APITimeoutError", + "AsyncDomains", + "AsyncEmailBuilder", + "AsyncEmails", "AsyncLettermint", - "ApiClient", - "AsyncApiClient", - # Webhook - "Webhook", - # Exceptions + "AsyncMessages", + "AsyncProjects", + "AsyncReportForwarding", + "AsyncRoutes", + "AsyncStats", + "AsyncSuppressions", + "AsyncTeam", + "AsyncTeamMembers", + "AsyncWebhookDeliveries", + "AsyncWebhooks", + "AuthenticationError", + "BaseEmailBuilder", + "ConflictError", + "CursorPage", + "Domains", + "EmailAttachment", + "EmailBuilder", + "EmailMessage", + "Emails", + "Lettermint", + "LettermintConfigError", "LettermintError", - "HttpRequestError", + "LettermintValidationError", + "Messages", + "NotFoundError", + "PermissionDeniedError", + "Projects", + "RateLimitError", + "RedirectError", + "ReportForwarding", + "Routes", + "ServerError", + "Stats", + "Suppressions", + "Team", + "TeamMembers", + "UnexpectedResponseError", "ValidationError", - "ClientError", - "TimeoutError", + "Webhook", + "WebhookDeliveries", + "WebhookHeaders", + "WebhookPayload", "WebhookVerificationError", - "InvalidSignatureError", - "TimestampToleranceError", - "JsonDecodeError", - # Types - "EmailAttachment", - "EmailPayload", - "EmailStatus", - "SendEmailResponse", - "SendBatchEmailResponse", - "MessageTag", + "WebhookVerificationReason", + "Webhooks", + "__version__", + "types", ] diff --git a/src/lettermint/_async/__init__.py b/src/lettermint/_async/__init__.py new file mode 100644 index 0000000..d9870e4 --- /dev/null +++ b/src/lettermint/_async/__init__.py @@ -0,0 +1,36 @@ +"""The asynchronous client. ``scripts/unasync.py`` generates ``lettermint._sync`` from it.""" + +from .client import AsyncLettermint +from .emails import AsyncEmailBuilder, AsyncEmails +from .resources import ( + AsyncDomains, + AsyncMessages, + AsyncProjects, + AsyncReportForwarding, + AsyncResource, + AsyncRoutes, + AsyncStats, + AsyncSuppressions, + AsyncTeam, + AsyncTeamMembers, + AsyncWebhookDeliveries, + AsyncWebhooks, +) + +__all__ = [ + "AsyncDomains", + "AsyncEmailBuilder", + "AsyncEmails", + "AsyncLettermint", + "AsyncMessages", + "AsyncProjects", + "AsyncReportForwarding", + "AsyncResource", + "AsyncRoutes", + "AsyncStats", + "AsyncSuppressions", + "AsyncTeam", + "AsyncTeamMembers", + "AsyncWebhookDeliveries", + "AsyncWebhooks", +] diff --git a/src/lettermint/_async/client.py b/src/lettermint/_async/client.py new file mode 100644 index 0000000..76ca6ad --- /dev/null +++ b/src/lettermint/_async/client.py @@ -0,0 +1,154 @@ +"""The asynchronous client, :class:`AsyncLettermint`.""" + +from __future__ import annotations + +from types import TracebackType +from typing import Any, cast + +import httpx +from typing_extensions import Self + +from .._core import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, build_config, describe_client +from .._generated.types import AnalyticsQuery, AnalyticsResponse, BlockedFileTypes +from .._transport import AsyncTransport +from ..exceptions import LettermintConfigError +from .emails import AsyncEmails +from .resources import ( + AsyncDomains, + AsyncMessages, + AsyncProjects, + AsyncRoutes, + AsyncStats, + AsyncSuppressions, + AsyncTeam, + AsyncWebhooks, +) + +__all__ = ["AsyncLettermint"] + + +class AsyncLettermint: + """The Lettermint client. + + :: + + lettermint = AsyncLettermint(sending_token=..., team_token=...) + lettermint = AsyncLettermint("lm_...") # team or sending token, detected by its prefix + + ``emails`` uses the sending token; every other part uses the team token. + The client holds no message state, so create it once and share it. Close + it when you are done, or use it as a context manager:: + + async with AsyncLettermint(sending_token=token) as lettermint: + await lettermint.emails.send({...}) + """ + + #: Send email. Needs ``sending_token``. + emails: AsyncEmails + #: Sending domains. Needs ``team_token``. + domains: AsyncDomains + #: Sent and received messages. Needs ``team_token``. + messages: AsyncMessages + #: Projects and their report forwarding. Needs ``team_token``. + projects: AsyncProjects + #: Routes of a project. Needs ``team_token``. + routes: AsyncRoutes + #: Sending statistics. Needs ``team_token``. + stats: AsyncStats + #: The suppression list. Needs ``team_token``. + suppressions: AsyncSuppressions + #: The team and its members. Needs ``team_token``. + team: AsyncTeam + #: Webhook endpoints and their deliveries. Needs ``team_token``. + webhooks: AsyncWebhooks + + def __init__( + self, + token: str | None = None, + /, + *, + sending_token: str | None = None, + team_token: str | None = None, + base_url: str = DEFAULT_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + http_client: httpx.AsyncClient | None = None, + ) -> None: + """Creates a client. + + Args: + token: A token string; ``lm_team_...`` is a team token, any other + ``lm_...`` token a sending token. Other formats raise + :class:`~lettermint.LettermintConfigError`. + sending_token: A project sending token, sent as ``x-lettermint-token``. + team_token: A team API token, sent as ``Authorization: Bearer``. + base_url: The API base URL. + timeout: Seconds for each request, covering the response headers and body. + http_client: An ``httpx`` client to send requests with, for example + for a proxy. The SDK never follows redirects and does not close + a client it did not create. + """ + config = build_config(token, sending_token, team_token, base_url, timeout) + if http_client is not None and not isinstance(http_client, httpx.AsyncClient): + raise LettermintConfigError("http_client must be an httpx.AsyncClient.") + client = http_client or httpx.AsyncClient( + follow_redirects=False, timeout=httpx.Timeout(config.timeout) + ) + self._transport = AsyncTransport(config, client, owns_client=http_client is None) + self.emails = AsyncEmails(self._transport) + self.domains = AsyncDomains(self._transport) + self.messages = AsyncMessages(self._transport) + self.projects = AsyncProjects(self._transport) + self.routes = AsyncRoutes(self._transport) + self.stats = AsyncStats(self._transport) + self.suppressions = AsyncSuppressions(self._transport) + self.team = AsyncTeam(self._transport) + self.webhooks = AsyncWebhooks(self._transport) + + async def ping(self, *, timeout: float | None = None) -> str: + """Checks the token: ``GET /ping`` returns ``pong``. + + Uses the team token when it is set, otherwise the sending token. + """ + text = await self._transport.request("GET /ping", label="ping", timeout=timeout) + return cast(str, text).strip() + + async def analytics( + self, query: AnalyticsQuery, *, timeout: float | None = None + ) -> AnalyticsResponse: + """Queries email analytics. Needs ``team_token``.""" + return cast( + AnalyticsResponse, + await self._transport.request( + "POST /analytics", label="analytics", body=query, timeout=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( + BlockedFileTypes, + await self._transport.request( + "GET /blocked-file-types", label="blocked_file_types", timeout=timeout + ), + ) + + async def close(self) -> None: + """Closes the HTTP client, if the SDK created it. Later calls raise ``LettermintConfigError``.""" + await self._transport.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.close() + + def __repr__(self) -> str: + return describe_client(type(self).__name__, self._transport.config) + + def __reduce__(self) -> Any: + raise TypeError(f"{type(self).__name__} cannot be pickled") diff --git a/src/lettermint/_async/emails.py b/src/lettermint/_async/emails.py new file mode 100644 index 0000000..7e87e6a --- /dev/null +++ b/src/lettermint/_async/emails.py @@ -0,0 +1,101 @@ +"""Sending email: :class:`AsyncEmails` and the immutable :class:`AsyncEmailBuilder`.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, cast + +from .._core import auth_header +from .._emails import BaseEmailBuilder, EmailMessage, check_message, initial_message +from .._generated.types import SendBatchMailResponse, SendMailResponse +from ..exceptions import LettermintValidationError +from .resources import AsyncResource + +__all__ = ["AsyncEmailBuilder", "AsyncEmails"] + + +class AsyncEmailBuilder(BaseEmailBuilder): + """An immutable email builder, created by ``lettermint.emails.compose()``. + + Every setter returns a new builder and leaves this one unchanged, so a base + builder can be shared and reused safely:: + + welcome = lettermint.emails.compose().from_("Acme ").subject("Welcome") + await welcome.to("jane@example.com").html("

Hi Jane

").send() + """ + + __slots__ = () + + async def send( + self, *, idempotency_key: str | None = None, timeout: float | None = None + ) -> SendMailResponse: + """Sends a snapshot of this email. The builder is unchanged and can be sent again.""" + emails = cast(AsyncEmails, self._emails) + return await emails.send( + cast(EmailMessage, self._message), idempotency_key=idempotency_key, timeout=timeout + ) + + +class AsyncEmails(AsyncResource): + """Sends email with the project sending token (``x-lettermint-token``). + + Holds no message state: every call sends exactly what it is given. + """ + + async def send( + self, + message: EmailMessage, + *, + idempotency_key: str | None = None, + timeout: float | None = None, + ) -> SendMailResponse: + """Sends one email, given in the API's wire format (``reply_to``, ``scheduled_at``, ...).""" + auth_header(self._transport.config, "emails.send", "sending") + body = check_message(message) + return cast( + SendMailResponse, + await self._request( + "POST /send", + "emails.send", + body=body, + idempotency_key=idempotency_key, + timeout=timeout, + ), + ) + + async def send_batch( + self, + messages: Sequence[EmailMessage | AsyncEmailBuilder], + *, + idempotency_key: str | None = None, + timeout: float | None = None, + ) -> SendBatchMailResponse: + """Sends up to 500 emails in one request. Accepts messages and builders.""" + auth_header(self._transport.config, "emails.send_batch", "sending") + if not isinstance(messages, (list, tuple)): + raise LettermintValidationError( + "send_batch() takes a list of messages.", field="messages" + ) + body: list[Any] = [ + check_message(message, f"messages[{index}]") for index, message in enumerate(messages) + ] + return cast( + SendBatchMailResponse, + await self._request( + "POST /send/batch", + "emails.send_batch", + body=body, + idempotency_key=idempotency_key, + timeout=timeout, + ), + ) + + def compose(self, message: EmailMessage | None = None) -> AsyncEmailBuilder: + """Starts an immutable email builder, empty or from ``message``.""" + auth_header(self._transport.config, "emails.compose", "sending") + return AsyncEmailBuilder(self, None if message is None else initial_message(message)) + + async def ping(self, *, timeout: float | None = None) -> str: + """Checks the sending token: ``GET /ping`` returns ``pong``.""" + text = await self._request("GET /ping", "emails.ping", auth="sending", timeout=timeout) + return cast(str, text).strip() diff --git a/src/lettermint/_async/resources.py b/src/lettermint/_async/resources.py new file mode 100644 index 0000000..ad1ff7e --- /dev/null +++ b/src/lettermint/_async/resources.py @@ -0,0 +1,955 @@ +"""The Team API sub-clients of :class:`~lettermint.AsyncLettermint`. + +Every method uses the team token, except ``messages.reschedule`` and +``messages.cancel``, which use the team token when it is set and otherwise +the sending token. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Mapping +from typing import Any, cast + +from .._generated.operations import OPERATIONS +from .._generated.types import ( + DeleteSuppressionResponse, + DnsVerificationSuccessResponse, + DomainData, + DomainListData, + DomainMutationResponse, + GetDomainQuery, + GetProjectQuery, + GetReportForwardingResponse, + GetRouteQuery, + GetStatsQuery, + GetTeamQuery, + InboundDomainVerificationResponse, + ListDomainsQuery, + ListDomainsResponse, + ListMessageEventsQuery, + ListMessageEventsResponse, + ListMessagesQuery, + ListMessagesResponse, + ListProjectsQuery, + ListProjectsResponse, + ListRoutesQuery, + ListRoutesResponse, + ListSuppressionsQuery, + ListSuppressionsResponse, + ListTeamMembersQuery, + ListTeamMembersResponse, + ListWebhookDeliveriesQuery, + ListWebhookDeliveriesResponse, + ListWebhooksQuery, + ListWebhooksResponse, + MessageData, + MessageEventData, + MessageListData, + MessageResponse, + ProcessInboundMessageResponse, + ProjectCreatedData, + ProjectData, + ProjectListData, + ProjectMutationResponse, + ReportForwardingRequest, + RescheduleMessageRequest, + ResendReportForwardingCodeResponse, + RotateProjectTokenResponse, + RouteData, + RouteListData, + RouteMutationResponse, + ScheduledMessage, + StatsData, + StoreDomainData, + StoreProjectData, + StoreRouteData, + StoreSuppressionData, + StoreWebhookData, + SuppressedRecipientData, + SuppressionStoreResponse, + TeamData, + TeamMemberData, + TeamMutationResponse, + TeamRoleListResponse, + TeamUsageDetailData, + TestWebhookResponse, + UpdateDomainProjectsData, + UpdateProjectData, + UpdateReportForwardingResponse, + UpdateRouteData, + UpdateTeamData, + UpdateTeamMemberAssignmentData, + UpdateWebhookData, + VerifyReportForwardingRequest, + VerifyReportForwardingResponse, + WebhookData, + WebhookDeliveryData, + WebhookDeliveryListData, + WebhookListData, + WebhookMutationResponse, + WebhookSecretResponse, +) +from .._query import with_query_param +from .._transport import AsyncTransport +from ..exceptions import UnexpectedResponseError + +__all__ = [ + "AsyncDomains", + "AsyncMessages", + "AsyncProjects", + "AsyncReportForwarding", + "AsyncRoutes", + "AsyncStats", + "AsyncSuppressions", + "AsyncTeam", + "AsyncTeamMembers", + "AsyncWebhookDeliveries", + "AsyncWebhooks", +] + + +async def _paginate( + transport: AsyncTransport, + key: str, + label: str, + path: Mapping[str, str] | None, + query: Mapping[str, Any] | None, + timeout: float | None, +) -> AsyncIterator[Any]: + """Follows ``next_cursor`` until it is ``None``, or repeats a cursor.""" + pagination = OPERATIONS[key].pagination + assert pagination is not None and pagination.cursor_param is not None + seen: set[str] = set() + current = query + while True: + page = await transport.request(key, label=label, path=path, query=current, timeout=timeout) + data = page.get("data") if isinstance(page, dict) else None + if not isinstance(data, list): + raise UnexpectedResponseError( + f"{label}: the API returned a page without a data array.", + status=200, + body=json.dumps(page), + ) + for item in data: + yield item + cursor = page.get("next_cursor") + if not isinstance(cursor, str) or not cursor or cursor in seen: + return + seen.add(cursor) + current = with_query_param(query, pagination.cursor_param, cursor) + + +class AsyncResource: + """Base of the sub-clients. ``repr()`` shows no configuration and no credentials.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def _request(self, key: str, label: str, **arguments: Any) -> Any: + return await self._transport.request(key, label=label, **arguments) + + def _paginate( + self, + key: str, + label: str, + *, + path: Mapping[str, str] | None = None, + query: Mapping[str, Any] | None = None, + timeout: float | None = None, + ) -> AsyncIterator[Any]: + # Check the token, path parameters and options now, not at the first page. + self._transport.prepare(key, label=label, path=path, query=query, timeout=timeout) + return _paginate(self._transport, key, label, path, query, timeout) + + def __repr__(self) -> str: + return f"{type(self).__name__}()" + + def __reduce__(self) -> Any: + raise TypeError(f"{type(self).__name__} cannot be pickled") + + +class AsyncDomains(AsyncResource): + """Sending domains. Team token.""" + + async def list( + self, query: ListDomainsQuery | None = None, *, timeout: float | None = None + ) -> ListDomainsResponse: + """Lists domains, one page at a time.""" + return cast( + ListDomainsResponse, + await self._request("GET /domains", "domains.list", query=query, timeout=timeout), + ) + + def iterate( + self, query: ListDomainsQuery | None = None, *, timeout: float | None = None + ) -> AsyncIterator[DomainListData]: + """Iterates over every domain, following ``next_cursor``.""" + return self._paginate("GET /domains", "domains.iterate", query=query, timeout=timeout) + + async def create(self, body: StoreDomainData, *, timeout: float | None = None) -> DomainData: + return cast( + DomainData, + await self._request("POST /domains", "domains.create", body=body, timeout=timeout), + ) + + async def retrieve( + self, domain_id: str, query: GetDomainQuery | None = None, *, timeout: float | None = None + ) -> DomainData: + return cast( + DomainData, + await self._request( + "GET /domains/{domainId}", + "domains.retrieve", + path={"domainId": domain_id}, + query=query, + timeout=timeout, + ), + ) + + async def delete(self, domain_id: str, *, timeout: float | None = None) -> MessageResponse: + return cast( + MessageResponse, + await self._request( + "DELETE /domains/{domainId}", + "domains.delete", + path={"domainId": domain_id}, + timeout=timeout, + ), + ) + + async def verify_dns_records( + self, domain_id: str, *, timeout: float | None = None + ) -> DnsVerificationSuccessResponse: + """Checks every DNS record of the domain.""" + return cast( + DnsVerificationSuccessResponse, + await self._request( + "POST /domains/{domainId}/dns-records/verify", + "domains.verify_dns_records", + path={"domainId": domain_id}, + timeout=timeout, + ), + ) + + async def verify_dns_record( + self, domain_id: str, record_id: str, *, timeout: float | None = None + ) -> MessageResponse: + """Checks one DNS record of the domain.""" + return cast( + MessageResponse, + await self._request( + "POST /domains/{domainId}/dns-records/{recordId}/verify", + "domains.verify_dns_record", + path={"domainId": domain_id, "recordId": record_id}, + timeout=timeout, + ), + ) + + async def update_projects( + self, domain_id: str, body: UpdateDomainProjectsData, *, timeout: float | None = None + ) -> DomainMutationResponse: + """Replaces the projects that may send from the domain.""" + return cast( + DomainMutationResponse, + await self._request( + "PUT /domains/{domainId}/projects", + "domains.update_projects", + path={"domainId": domain_id}, + body=body, + timeout=timeout, + ), + ) + + +class AsyncMessages(AsyncResource): + """Sent and received messages. Team token. + + ``reschedule`` and ``cancel`` also accept the sending token when no team + token is configured. + """ + + async def list( + self, query: ListMessagesQuery | None = None, *, timeout: float | None = None + ) -> ListMessagesResponse: + """Lists messages, one page at a time.""" + return cast( + ListMessagesResponse, + await self._request("GET /messages", "messages.list", query=query, timeout=timeout), + ) + + def iterate( + self, query: ListMessagesQuery | None = None, *, timeout: float | None = None + ) -> AsyncIterator[MessageListData]: + """Iterates over every message, following ``next_cursor``.""" + return self._paginate("GET /messages", "messages.iterate", query=query, timeout=timeout) + + async def retrieve(self, message_id: str, *, timeout: float | None = None) -> MessageData: + return cast( + MessageData, + await self._request( + "GET /messages/{messageId}", + "messages.retrieve", + path={"messageId": message_id}, + timeout=timeout, + ), + ) + + async def events( + self, + message_id: str, + query: ListMessageEventsQuery | None = None, + *, + timeout: float | None = None, + ) -> ListMessageEventsResponse: + """Lists the events of a message, one page at a time.""" + return cast( + ListMessageEventsResponse, + await self._request( + "GET /messages/{messageId}/events", + "messages.events", + path={"messageId": message_id}, + query=query, + timeout=timeout, + ), + ) + + def iterate_events( + self, + message_id: str, + query: ListMessageEventsQuery | None = None, + *, + timeout: float | None = None, + ) -> AsyncIterator[MessageEventData]: + """Iterates over every event of a message, following ``next_cursor``.""" + return self._paginate( + "GET /messages/{messageId}/events", + "messages.iterate_events", + path={"messageId": message_id}, + query=query, + timeout=timeout, + ) + + async def source(self, message_id: str, *, timeout: float | None = None) -> str: + """The raw RFC 822 source.""" + return cast( + str, + await self._request( + "GET /messages/{messageId}/source", + "messages.source", + path={"messageId": message_id}, + timeout=timeout, + ), + ) + + async def html(self, message_id: str, *, timeout: float | None = None) -> str: + """The HTML body.""" + return cast( + str, + await self._request( + "GET /messages/{messageId}/html", + "messages.html", + path={"messageId": message_id}, + timeout=timeout, + ), + ) + + async def text(self, message_id: str, *, timeout: float | None = None) -> str: + """The plain-text body.""" + return cast( + str, + await self._request( + "GET /messages/{messageId}/text", + "messages.text", + path={"messageId": message_id}, + timeout=timeout, + ), + ) + + async def reschedule( + self, message_id: str, body: RescheduleMessageRequest, *, timeout: float | None = None + ) -> ScheduledMessage: + """Moves a scheduled message to another delivery time.""" + return cast( + ScheduledMessage, + await self._request( + "PATCH /messages/{messageId}", + "messages.reschedule", + path={"messageId": message_id}, + body=body, + timeout=timeout, + ), + ) + + async def cancel(self, message_id: str, *, timeout: float | None = None) -> ScheduledMessage: + """Cancels a scheduled message.""" + return cast( + ScheduledMessage, + await self._request( + "POST /messages/{messageId}/cancel", + "messages.cancel", + path={"messageId": message_id}, + timeout=timeout, + ), + ) + + async def process( + self, + message_id: str, + *, + idempotency_key: str | None = None, + timeout: float | None = None, + ) -> ProcessInboundMessageResponse: + """Releases one quarantined inbound message for webhook delivery.""" + return cast( + ProcessInboundMessageResponse, + await self._request( + "POST /messages/{messageId}/process", + "messages.process", + path={"messageId": message_id}, + idempotency_key=idempotency_key, + timeout=timeout, + ), + ) + + +class AsyncReportForwarding(AsyncResource): + """DMARC and complaint report forwarding of a project. Team token.""" + + async def retrieve( + self, project_id: str, *, timeout: float | None = None + ) -> GetReportForwardingResponse: + return cast( + GetReportForwardingResponse, + await self._request( + "GET /projects/{projectId}/report-forwarding", + "projects.report_forwarding.retrieve", + path={"projectId": project_id}, + timeout=timeout, + ), + ) + + async def update( + self, project_id: str, body: ReportForwardingRequest, *, timeout: float | None = None + ) -> UpdateReportForwardingResponse: + return cast( + UpdateReportForwardingResponse, + await self._request( + "PUT /projects/{projectId}/report-forwarding", + "projects.report_forwarding.update", + path={"projectId": project_id}, + body=body, + timeout=timeout, + ), + ) + + async def delete(self, project_id: str, *, timeout: float | None = None) -> None: + """Disables report forwarding (HTTP 204).""" + await self._request( + "DELETE /projects/{projectId}/report-forwarding", + "projects.report_forwarding.delete", + path={"projectId": project_id}, + timeout=timeout, + ) + + async def verify( + self, project_id: str, body: VerifyReportForwardingRequest, *, timeout: float | None = None + ) -> VerifyReportForwardingResponse: + return cast( + VerifyReportForwardingResponse, + await self._request( + "POST /projects/{projectId}/report-forwarding/verify", + "projects.report_forwarding.verify", + path={"projectId": project_id}, + body=body, + timeout=timeout, + ), + ) + + async def resend_code( + self, project_id: str, *, timeout: float | None = None + ) -> ResendReportForwardingCodeResponse: + return cast( + ResendReportForwardingCodeResponse, + await self._request( + "POST /projects/{projectId}/report-forwarding/resend-code", + "projects.report_forwarding.resend_code", + path={"projectId": project_id}, + timeout=timeout, + ), + ) + + +class AsyncProjects(AsyncResource): + """Projects. Team token.""" + + def __init__(self, transport: AsyncTransport) -> None: + super().__init__(transport) + #: Report forwarding of a project. + self.report_forwarding = AsyncReportForwarding(transport) + + async def list( + self, query: ListProjectsQuery | None = None, *, timeout: float | None = None + ) -> ListProjectsResponse: + """Lists projects, one page at a time.""" + return cast( + ListProjectsResponse, + await self._request("GET /projects", "projects.list", query=query, timeout=timeout), + ) + + def iterate( + self, query: ListProjectsQuery | None = None, *, timeout: float | None = None + ) -> AsyncIterator[ProjectListData]: + """Iterates over every project, following ``next_cursor``.""" + return self._paginate("GET /projects", "projects.iterate", query=query, timeout=timeout) + + async def create( + self, body: StoreProjectData, *, timeout: float | None = None + ) -> ProjectCreatedData: + """Creates a project. The response holds its sending token once (``api_token``).""" + return cast( + ProjectCreatedData, + await self._request("POST /projects", "projects.create", body=body, timeout=timeout), + ) + + async def retrieve( + self, project_id: str, query: GetProjectQuery | None = None, *, timeout: float | None = None + ) -> ProjectData: + return cast( + ProjectData, + await self._request( + "GET /projects/{projectId}", + "projects.retrieve", + path={"projectId": project_id}, + query=query, + timeout=timeout, + ), + ) + + async def update( + self, project_id: str, body: UpdateProjectData, *, timeout: float | None = None + ) -> ProjectMutationResponse: + return cast( + ProjectMutationResponse, + await self._request( + "PUT /projects/{projectId}", + "projects.update", + path={"projectId": project_id}, + body=body, + timeout=timeout, + ), + ) + + async def delete(self, project_id: str, *, timeout: float | None = None) -> MessageResponse: + return cast( + MessageResponse, + await self._request( + "DELETE /projects/{projectId}", + "projects.delete", + path={"projectId": project_id}, + timeout=timeout, + ), + ) + + async def rotate_token( + self, project_id: str, *, timeout: float | None = None + ) -> RotateProjectTokenResponse: + """Rotates the project's legacy sending token. Deprecated by the API.""" + return cast( + RotateProjectTokenResponse, + await self._request( + "POST /projects/{projectId}/rotate-token", + "projects.rotate_token", + path={"projectId": project_id}, + timeout=timeout, + ), + ) + + +class AsyncRoutes(AsyncResource): + """Routes of a project. Team token.""" + + async def list( + self, project_id: str, query: ListRoutesQuery | None = None, *, timeout: float | None = None + ) -> ListRoutesResponse: + """Lists the routes of a project, one page at a time.""" + return cast( + ListRoutesResponse, + await self._request( + "GET /projects/{projectId}/routes", + "routes.list", + path={"projectId": project_id}, + query=query, + timeout=timeout, + ), + ) + + def iterate( + self, project_id: str, query: ListRoutesQuery | None = None, *, timeout: float | None = None + ) -> AsyncIterator[RouteListData]: + """Iterates over every route of a project, following ``next_cursor``.""" + return self._paginate( + "GET /projects/{projectId}/routes", + "routes.iterate", + path={"projectId": project_id}, + query=query, + timeout=timeout, + ) + + async def create( + self, project_id: str, body: StoreRouteData, *, timeout: float | None = None + ) -> RouteMutationResponse: + return cast( + RouteMutationResponse, + await self._request( + "POST /projects/{projectId}/routes", + "routes.create", + path={"projectId": project_id}, + body=body, + timeout=timeout, + ), + ) + + async def retrieve( + self, route_id: str, query: GetRouteQuery | None = None, *, timeout: float | None = None + ) -> RouteData: + return cast( + RouteData, + await self._request( + "GET /routes/{routeId}", + "routes.retrieve", + path={"routeId": route_id}, + query=query, + timeout=timeout, + ), + ) + + async def update( + self, route_id: str, body: UpdateRouteData, *, timeout: float | None = None + ) -> RouteMutationResponse: + return cast( + RouteMutationResponse, + await self._request( + "PUT /routes/{routeId}", + "routes.update", + path={"routeId": route_id}, + body=body, + timeout=timeout, + ), + ) + + async def delete(self, route_id: str, *, timeout: float | None = None) -> MessageResponse: + return cast( + MessageResponse, + await self._request( + "DELETE /routes/{routeId}", + "routes.delete", + path={"routeId": route_id}, + timeout=timeout, + ), + ) + + async def verify_inbound_domain( + self, route_id: str, *, timeout: float | None = None + ) -> InboundDomainVerificationResponse: + return cast( + InboundDomainVerificationResponse, + await self._request( + "POST /routes/{routeId}/verify-inbound-domain", + "routes.verify_inbound_domain", + path={"routeId": route_id}, + timeout=timeout, + ), + ) + + +class AsyncStats(AsyncResource): + """Sending statistics. Team token.""" + + async def retrieve(self, query: GetStatsQuery, *, timeout: float | None = None) -> StatsData: + """Daily statistics between ``from`` and ``to`` (``YYYY-MM-DD``, at most 90 days).""" + return cast( + StatsData, + await self._request("GET /stats", "stats.retrieve", query=query, timeout=timeout), + ) + + +class AsyncSuppressions(AsyncResource): + """The suppression list. Team token.""" + + async def list( + self, query: ListSuppressionsQuery | None = None, *, timeout: float | None = None + ) -> ListSuppressionsResponse: + """Lists suppressions, one page at a time.""" + return cast( + ListSuppressionsResponse, + await self._request( + "GET /suppressions", "suppressions.list", query=query, timeout=timeout + ), + ) + + def iterate( + self, query: ListSuppressionsQuery | None = None, *, timeout: float | None = None + ) -> AsyncIterator[SuppressedRecipientData]: + """Iterates over every suppression, following ``next_cursor``.""" + return self._paginate( + "GET /suppressions", "suppressions.iterate", query=query, timeout=timeout + ) + + async def create( + self, body: StoreSuppressionData, *, timeout: float | None = None + ) -> SuppressionStoreResponse: + return cast( + SuppressionStoreResponse, + await self._request( + "POST /suppressions", "suppressions.create", body=body, timeout=timeout + ), + ) + + async def delete( + self, suppression_id: str, *, timeout: float | None = None + ) -> DeleteSuppressionResponse: + return cast( + DeleteSuppressionResponse, + await self._request( + "DELETE /suppressions/{suppressionId}", + "suppressions.delete", + path={"suppressionId": suppression_id}, + timeout=timeout, + ), + ) + + +class AsyncTeamMembers(AsyncResource): + """Team members. Team token.""" + + async def list( + self, query: ListTeamMembersQuery | None = None, *, timeout: float | None = None + ) -> ListTeamMembersResponse: + """Lists team members, one page at a time.""" + return cast( + ListTeamMembersResponse, + await self._request( + "GET /team/members", "team.members.list", query=query, timeout=timeout + ), + ) + + def iterate( + self, query: ListTeamMembersQuery | None = None, *, timeout: float | None = None + ) -> AsyncIterator[TeamMemberData]: + """Iterates over every team member, following ``next_cursor``.""" + return self._paginate( + "GET /team/members", "team.members.iterate", query=query, timeout=timeout + ) + + async def retrieve(self, user_id: str, *, timeout: float | None = None) -> TeamMemberData: + return cast( + TeamMemberData, + await self._request( + "GET /team/members/{userId}", + "team.members.retrieve", + path={"userId": user_id}, + timeout=timeout, + ), + ) + + async def update_assignment( + self, user_id: str, body: UpdateTeamMemberAssignmentData, *, timeout: float | None = None + ) -> TeamMemberData: + """Changes a member's role and project access.""" + return cast( + TeamMemberData, + await self._request( + "PUT /team/members/{userId}/assignment", + "team.members.update_assignment", + path={"userId": user_id}, + body=body, + timeout=timeout, + ), + ) + + +class AsyncTeam(AsyncResource): + """The team of the token. Team token.""" + + def __init__(self, transport: AsyncTransport) -> None: + super().__init__(transport) + #: Team members. + self.members = AsyncTeamMembers(transport) + + async def retrieve( + self, query: GetTeamQuery | None = None, *, timeout: float | None = None + ) -> TeamData: + return cast( + TeamData, + await self._request("GET /team", "team.retrieve", query=query, timeout=timeout), + ) + + async def update( + self, body: UpdateTeamData, *, timeout: float | None = None + ) -> TeamMutationResponse: + return cast( + TeamMutationResponse, + await self._request("PUT /team", "team.update", body=body, timeout=timeout), + ) + + async def usage(self, *, timeout: float | None = None) -> TeamUsageDetailData: + """Usage of the current and previous billing periods.""" + return cast( + TeamUsageDetailData, + await self._request("GET /team/usage", "team.usage", timeout=timeout), + ) + + async def roles(self, *, timeout: float | None = None) -> TeamRoleListResponse: + """The roles that can be assigned to members.""" + return cast( + TeamRoleListResponse, + await self._request("GET /team/roles", "team.roles", timeout=timeout), + ) + + +class AsyncWebhookDeliveries(AsyncResource): + """Delivery attempts of a webhook. Team token.""" + + async def list( + self, + webhook_id: str, + query: ListWebhookDeliveriesQuery | None = None, + *, + timeout: float | None = None, + ) -> ListWebhookDeliveriesResponse: + """Lists the deliveries of a webhook, one page at a time.""" + return cast( + ListWebhookDeliveriesResponse, + await self._request( + "GET /webhooks/{webhookId}/deliveries", + "webhooks.deliveries.list", + path={"webhookId": webhook_id}, + query=query, + timeout=timeout, + ), + ) + + def iterate( + self, + webhook_id: str, + query: ListWebhookDeliveriesQuery | None = None, + *, + timeout: float | None = None, + ) -> AsyncIterator[WebhookDeliveryListData]: + """Iterates over every delivery of a webhook, following ``next_cursor``.""" + return self._paginate( + "GET /webhooks/{webhookId}/deliveries", + "webhooks.deliveries.iterate", + path={"webhookId": webhook_id}, + query=query, + timeout=timeout, + ) + + async def retrieve( + self, webhook_id: str, delivery_id: str, *, timeout: float | None = None + ) -> WebhookDeliveryData: + return cast( + WebhookDeliveryData, + await self._request( + "GET /webhooks/{webhookId}/deliveries/{deliveryId}", + "webhooks.deliveries.retrieve", + path={"webhookId": webhook_id, "deliveryId": delivery_id}, + timeout=timeout, + ), + ) + + +class AsyncWebhooks(AsyncResource): + """Webhook endpoints. Team token. To verify incoming deliveries, use :class:`~lettermint.Webhook`.""" + + def __init__(self, transport: AsyncTransport) -> None: + super().__init__(transport) + #: Delivery attempts of a webhook. + self.deliveries = AsyncWebhookDeliveries(transport) + + async def list( + self, query: ListWebhooksQuery | None = None, *, timeout: float | None = None + ) -> ListWebhooksResponse: + """Lists webhooks, one page at a time.""" + return cast( + ListWebhooksResponse, + await self._request("GET /webhooks", "webhooks.list", query=query, timeout=timeout), + ) + + def iterate( + self, query: ListWebhooksQuery | None = None, *, timeout: float | None = None + ) -> AsyncIterator[WebhookListData]: + """Iterates over every webhook, following ``next_cursor``.""" + return self._paginate("GET /webhooks", "webhooks.iterate", query=query, timeout=timeout) + + async def create( + self, body: StoreWebhookData, *, timeout: float | None = None + ) -> WebhookSecretResponse: + """Creates a webhook. The response holds its signing secret once.""" + return cast( + WebhookSecretResponse, + await self._request("POST /webhooks", "webhooks.create", body=body, timeout=timeout), + ) + + async def retrieve(self, webhook_id: str, *, timeout: float | None = None) -> WebhookData: + return cast( + WebhookData, + await self._request( + "GET /webhooks/{webhookId}", + "webhooks.retrieve", + path={"webhookId": webhook_id}, + timeout=timeout, + ), + ) + + async def update( + self, webhook_id: str, body: UpdateWebhookData, *, timeout: float | None = None + ) -> WebhookMutationResponse: + return cast( + WebhookMutationResponse, + await self._request( + "PUT /webhooks/{webhookId}", + "webhooks.update", + path={"webhookId": webhook_id}, + body=body, + timeout=timeout, + ), + ) + + async def delete(self, webhook_id: str, *, timeout: float | None = None) -> MessageResponse: + return cast( + MessageResponse, + await self._request( + "DELETE /webhooks/{webhookId}", + "webhooks.delete", + path={"webhookId": webhook_id}, + timeout=timeout, + ), + ) + + async def test(self, webhook_id: str, *, timeout: float | None = None) -> TestWebhookResponse: + """Sends a ``webhook.test`` delivery.""" + return cast( + TestWebhookResponse, + await self._request( + "POST /webhooks/{webhookId}/test", + "webhooks.test", + path={"webhookId": webhook_id}, + timeout=timeout, + ), + ) + + async def regenerate_secret( + self, webhook_id: str, *, timeout: float | None = None + ) -> WebhookSecretResponse: + """Replaces the signing secret. The response holds the new secret once.""" + return cast( + WebhookSecretResponse, + await self._request( + "POST /webhooks/{webhookId}/regenerate-secret", + "webhooks.regenerate_secret", + path={"webhookId": webhook_id}, + timeout=timeout, + ), + ) diff --git a/src/lettermint/_core.py b/src/lettermint/_core.py new file mode 100644 index 0000000..f2d8020 --- /dev/null +++ b/src/lettermint/_core.py @@ -0,0 +1,414 @@ +"""The I/O-free core shared by the synchronous and asynchronous clients. + +Token handling, option checks, request preparation, response decoding and +error mapping live here, once. The transports in ``_transport.py`` only move +bytes. +""" + +from __future__ import annotations + +import codecs +import contextlib +import json +import math +import platform +import re +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from email.utils import parsedate_to_datetime +from typing import Any, Literal, NoReturn, TypeAlias +from urllib.parse import quote, urlsplit + +from ._generated.operations import OPERATIONS, Operation +from ._query import serialize_query +from ._version import __version__ +from .exceptions import ( + APIError, + AuthenticationError, + ConflictError, + LettermintConfigError, + LettermintValidationError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ServerError, + UnexpectedResponseError, + ValidationError, +) + +DEFAULT_BASE_URL = "https://api.lettermint.co/v1" +DEFAULT_TIMEOUT = 30.0 +REDACTED = "[redacted]" +USER_AGENT = f"lettermint-python/{__version__} python/{platform.python_version()}" + +#: Team API tokens: ``ApiToken::TEAM_PREFIX`` in the Lettermint backend. +_TEAM_TOKEN = re.compile(r"lm_team_[0-9A-Za-z]+") +#: Project sending tokens (32 or 22 random characters): ``ApiToken::PROJECT_PREFIX``. +_SENDING_TOKEN = re.compile(r"lm_[0-9A-Za-z]+") +#: Characters allowed in a token, so that it is a valid HTTP header value. +_HEADER_SAFE = re.compile(r"[\x21-\x7e]+") +_HEADER_VALUE = re.compile(r"[^\r\n\0]+") +_PATH_PARAM = re.compile(r"\{(\w+)\}") +_CHARSET = re.compile(r"charset\s*=\s*\"?([\w.:-]+)", re.IGNORECASE) + +TokenKind: TypeAlias = Literal["sending", "team"] +AuthChoice: TypeAlias = Literal["sending", "team", "either"] + + +class Secret: + """A credential that never shows up in ``repr()``, ``str()``, ``vars()`` or pickles.""" + + __slots__ = ("_value",) + + def __init__(self, value: str) -> None: + self._value = value + + def reveal(self) -> str: + return self._value + + def __repr__(self) -> str: + return REDACTED + + __str__ = __repr__ + + def __format__(self, spec: str) -> str: + return REDACTED + + def __reduce__(self) -> NoReturn: + raise TypeError("Lettermint credentials cannot be pickled") + + +def detect_token_kind(token: object) -> TokenKind: + """Classifies a token passed as ``Lettermint(token)``. + + The team pattern is checked first, because every team token also starts + with ``lm_``. The error message never contains the token. + """ + if isinstance(token, str): + if _TEAM_TOKEN.fullmatch(token): + return "team" + if _SENDING_TOKEN.fullmatch(token): + return "sending" + raise LettermintConfigError( + "Unrecognised token format; pass sending_token=... or team_token=... instead." + ) + + +def check_token(option: str, token: object) -> Secret | None: + if token is None: + return None + if not isinstance(token, str) or not token: + raise LettermintConfigError(f"{option} must be a non-empty string.") + if not _HEADER_SAFE.fullmatch(token): + raise LettermintConfigError( + f"{option} contains whitespace or characters that are not allowed in an HTTP header." + ) + return Secret(token) + + +def check_timeout(value: object, option: str = "timeout") -> float: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value <= 0 + ): + raise LettermintConfigError(f"{option} must be a positive number of seconds.") + return float(value) + + +def check_base_url(value: object) -> str: + if not isinstance(value, str): + raise LettermintConfigError("base_url must be a string.") + try: + url = urlsplit(value) + port = url.port + except ValueError: + raise LettermintConfigError("base_url must be an absolute http(s) URL.") from None + if url.scheme not in ("http", "https") or not url.hostname: + raise LettermintConfigError("base_url must be an absolute http(s) URL.") + if url.username is not None or url.password is not None or url.query or url.fragment: + raise LettermintConfigError( + "base_url must not contain credentials, a query string or a fragment." + ) + del port + return value.rstrip("/") + + +@dataclass(frozen=True) +class Config: + """A client's settings. The tokens are :class:`Secret`\\ s, so ``repr()`` is safe.""" + + sending_token: Secret | None + team_token: Secret | None + base_url: str + timeout: float + + +def build_config( + token: object, + sending_token: object, + team_token: object, + base_url: object, + timeout: object, +) -> Config: + if token is not None: + if sending_token is not None or team_token is not None: + raise LettermintConfigError( + "Pass a token string or sending_token/team_token, not both." + ) + if detect_token_kind(token) == "team": + team_token = token + else: + sending_token = token + sending = check_token("sending_token", sending_token) + team = check_token("team_token", team_token) + if sending is None and team is None: + raise LettermintConfigError("Pass sending_token, team_token or both.") + return Config(sending, team, check_base_url(base_url), check_timeout(timeout)) + + +def describe_client(name: str, config: Config) -> str: + return ( + f"{name}(base_url={config.base_url!r}, timeout={config.timeout!r}, " + f"sending_token={REDACTED if config.sending_token else None}, " + f"team_token={REDACTED if config.team_token else None})" + ) + + +def auth_header(config: Config, label: str, auth: AuthChoice) -> tuple[str, Secret]: + """The header that carries the token for ``auth``. Never falls back to the other token.""" + if auth == "team" or (auth == "either" and config.team_token is not None): + if config.team_token is None: + raise LettermintConfigError( + f"{label} needs team_token; pass Lettermint(team_token=...)." + ) + return "Authorization", config.team_token + if config.sending_token is None: + raise LettermintConfigError( + f"{label} needs sending_token; pass Lettermint(sending_token=...)." + ) + return "x-lettermint-token", config.sending_token + + +def encode_path_param(label: str, name: str, value: object) -> str: + if not isinstance(value, str) or value in ("", ".", ".."): + raise LettermintConfigError( + f'{label}: {name} must be a non-empty string other than "." and "..".' + ) + return quote(value, safe="") + + +def check_idempotency_key(value: object) -> str: + if not isinstance(value, str) or not _HEADER_VALUE.fullmatch(value): + raise LettermintValidationError( + "idempotency_key must be a non-empty string without line breaks.", + field="idempotency_key", + ) + return value + + +@dataclass(frozen=True) +class Call: + """One prepared request. Holds the token as a :class:`Secret`, so ``repr()`` is safe.""" + + label: str + method: str + url: str + headers: tuple[tuple[str, str], ...] + content: bytes | None + auth: tuple[str, Secret] = field(repr=False) + timeout: float + response_type: str + + def request_headers(self) -> dict[str, str]: + """The headers including the credential. Use them only to send the request.""" + name, secret = self.auth + value = secret.reveal() + return {**dict(self.headers), name: f"Bearer {value}" if name == "Authorization" else value} + + +_NO_BODY: Any = object() + + +def _encode_json(body: Any) -> tuple[bytes | None, str]: + try: + text = json.dumps(body, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + except (TypeError, ValueError) as error: + return None, str(error) + return text.encode("utf-8"), "" + + +def prepare( + config: Config, + key: str, + *, + label: str, + path: Mapping[str, str] | None = None, + query: Mapping[str, Any] | None = None, + body: Any = _NO_BODY, + idempotency_key: str | None = None, + auth: AuthChoice | None = None, + timeout: float | None = None, +) -> Call: + """Checks a call and turns it into a :class:`Call`. Raises before any request.""" + operation: Operation = OPERATIONS[key] + credential = auth_header(config, label, auth or operation.auth) + params = path or {} + url_path = _PATH_PARAM.sub( + lambda match: encode_path_param(label, match.group(1), params.get(match.group(1))), + operation.path, + ) + seconds = config.timeout if timeout is None else check_timeout(timeout) + headers: list[tuple[str, str]] = [("Accept", "application/json"), ("User-Agent", USER_AGENT)] + if idempotency_key is not None: + headers.append(("Idempotency-Key", check_idempotency_key(idempotency_key))) + content: bytes | None = None + if body is not _NO_BODY: + content, problem = _encode_json(body) + if content is None: + raise LettermintValidationError( + f"{label}: the request body cannot be encoded as JSON ({problem}).", field="body" + ) + headers.append(("Content-Type", "application/json")) + if query is not None and not isinstance(query, Mapping): + raise LettermintConfigError(f"{label}: the query must be a mapping.") + query_string = serialize_query(query) + url = config.base_url + url_path + (f"?{query_string}" if query_string else "") + return Call( + label=label, + method=operation.method, + url=url, + headers=tuple(headers), + content=content, + auth=credential, + timeout=seconds, + response_type=operation.response.type, + ) + + +def scrub(text: str, config: Config) -> str: + """Removes both tokens from a message, in case a library echoes a header.""" + for secret in (config.sending_token, config.team_token): + if secret is not None: + text = text.replace(secret.reveal(), REDACTED) + return text + + +def _text(headers: Mapping[str, str], body: bytes) -> str: + match = _CHARSET.search(headers.get("content-type", "")) + encoding = "utf-8" + if match: + with contextlib.suppress(LookupError): + encoding = codecs.lookup(match.group(1)).name + return body.decode(encoding, errors="replace") + + +def parse_retry_after(value: str | None) -> int | None: + if not value: + return None + value = value.strip() + if value.isascii() and value.isdigit(): + return int(value) + try: + moment = parsedate_to_datetime(value) + except (TypeError, ValueError, IndexError): + return None + if moment.tzinfo is None: + return None + return max(0, math.ceil(moment.timestamp() - time.time())) + + +def _api_error(status: int, reason: str, headers: Mapping[str, str], body: Any) -> APIError: + message = "" + code: str | None = None + details: Any = None + errors: dict[str, list[str]] | None = None + if isinstance(body, dict): + error = body.get("error") + if isinstance(error, dict): + if isinstance(error.get("code"), str): + code = error["code"] + if isinstance(error.get("message"), str): + message = error["message"] + details = error.get("details") + elif isinstance(error, str): + code = error + if not message and isinstance(body.get("message"), str): + message = body["message"] + if isinstance(body.get("errors"), dict): + errors = body["errors"] + message = message or reason or f"HTTP {status}" + common: dict[str, Any] = {"status": status, "code": code, "details": details, "body": body} + if status == 401: + return AuthenticationError(message, **common) + if status == 403: + return PermissionDeniedError(message, **common) + if status == 404: + return NotFoundError(message, **common) + if status == 409: + return ConflictError(message, **common) + if status == 422: + return ValidationError(message, errors=errors, **common) + if status == 429: + return RateLimitError( + message, retry_after=parse_retry_after(headers.get("retry-after")), **common + ) + if status >= 500: + return ServerError(message, **common) + return APIError(message, **common) + + +_INVALID: Any = object() + + +def _parse_json(text: str) -> Any: + """The decoded JSON, or ``_INVALID``. Lets callers raise outside an ``except`` block.""" + try: + return json.loads(text) + except ValueError: + return _INVALID + + +def decode(call: Call, status: int, reason: str, headers: Mapping[str, str], body: bytes) -> Any: + """The decoded success body, or the matching exception. ``headers`` keys are lower case.""" + if 200 <= status < 300: + if call.response_type == "empty" or status in (204, 205): + return None + text = _text(headers, body) + if call.response_type == "text": + return text + if not text.strip(): + raise UnexpectedResponseError( + f"The Lettermint API answered with HTTP {status} and an empty body where JSON was expected.", + status=status, + body=text, + ) + value = _parse_json(text) + if value is _INVALID: + raise UnexpectedResponseError( + f"The Lettermint API answered with HTTP {status} and a body that is not valid JSON.", + status=status, + body=text, + ) + return value + text = _text(headers, body) + if status < 400: + raise UnexpectedResponseError( + f"The Lettermint API answered with an unexpected HTTP status {status}.", + status=status, + body=text, + ) + parsed: Any = None + if text.strip(): + parsed = _parse_json(text) + if parsed is _INVALID: + content_type = headers.get("content-type", "").split(";")[0].strip() + raise UnexpectedResponseError( + f"The Lettermint API answered with HTTP {status} and a body that is not JSON" + + (f" ({content_type})." if content_type else "."), + status=status, + body=text, + ) + raise _api_error(status, reason, headers, parsed) diff --git a/src/lettermint/_emails.py b/src/lettermint/_emails.py new file mode 100644 index 0000000..5615f12 --- /dev/null +++ b/src/lettermint/_emails.py @@ -0,0 +1,350 @@ +"""Email messages: validation, the wire format and the immutable builder base. + +Shared by the synchronous and asynchronous clients. Nothing here performs I/O +or keeps state between emails. +""" + +from __future__ import annotations + +import base64 +import copy +import re +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any, NoReturn + +from typing_extensions import Buffer, NotRequired, Required, Self, TypedDict + +from ._generated.types import ( + MessageTagInput, + SandboxResult, + SendMailRequest, + SendMailRequestSettings, +) +from .exceptions import LettermintValidationError + +__all__ = ["BaseEmailBuilder", "EmailAttachment", "EmailMessage"] + +_TAG_NAME = re.compile(r"[A-Za-z0-9_-]{1,32}") +_TAG_VALUE = re.compile(r"[A-Za-z0-9_-]{1,64}") +_MAX_TAGS = 20 + + +def as_bytes(value: object) -> bytes | None: + """A copy of ``value`` as ``bytes`` when it is binary (bytes, bytearray, memoryview, ...).""" + if isinstance(value, (str, int)) or value is None: + return None + if isinstance(value, bytes): + return value + try: + return bytes(memoryview(value)) # type: ignore[arg-type] + except TypeError: + return None + + +class EmailAttachment(TypedDict): + """An attachment of an :data:`EmailMessage`. ``content`` is base64 text or raw bytes.""" + + filename: Required[str] + #: Base64-encoded text, or raw bytes that the SDK encodes. + content: Required[str | Buffer] + #: MIME type, for example ``application/pdf``. Detected by the API when omitted. + content_type: NotRequired[str] + #: Content-ID for inline images referenced as ``cid:`` in the HTML. + content_id: NotRequired[str] + + +#: An email in the API's wire format (``reply_to``, ``scheduled_at``, ...): the +#: generated ``SendMailRequest``, except that attachment content may also be bytes. +EmailMessage = TypedDict( + "EmailMessage", + { + "route": NotRequired[str], + "from": Required[str], + "to": Required[list[str]], + "cc": NotRequired[list[str]], + "bcc": NotRequired[list[str]], + "reply_to": NotRequired[list[str]], + "subject": Required[str], + "scheduled_at": NotRequired[str], + "headers": NotRequired[dict[str, str]], + "metadata": NotRequired[dict[str, str]], + "tag": NotRequired["str | None"], + "tags": NotRequired[list[MessageTagInput]], + "settings": NotRequired[SendMailRequestSettings], + "html": NotRequired["str | None"], + "text": NotRequired["str | None"], + "attachments": NotRequired[list[EmailAttachment]], + "sandbox_result": NotRequired[SandboxResult], + }, +) + + +def _fail(field: str, message: str) -> NoReturn: + raise LettermintValidationError(message, field=field) + + +def _validate_tags(tags: object, has_legacy_tag: bool, field: str) -> None: + if tags is None: + return + if not isinstance(tags, (list, tuple)): + _fail(field, "Message tags must be a list of {name, value} mappings.") + maximum = _MAX_TAGS - 1 if has_legacy_tag else _MAX_TAGS + if len(tags) > maximum: + _fail( + field, + f"A legacy tag and no more than {maximum} message tags are permitted." + if has_legacy_tag + else f"No more than {maximum} message tags are permitted.", + ) + names: set[str] = set() + for tag in tags: + if ( + not isinstance(tag, Mapping) + or not isinstance(tag.get("name"), str) + or not isinstance(tag.get("value"), str) + ): + _fail(field, "Message tags must be {name, value} mappings with string values.") + name, value = tag["name"], tag["value"] + if not _TAG_NAME.fullmatch(name): + _fail(field, "Message tag names must match ^[A-Za-z0-9_-]{1,32}$.") + if name.lower().startswith("__lettermint"): + _fail(field, "Message tag names must not start with __lettermint.") + if not _TAG_VALUE.fullmatch(value): + _fail(field, "Message tag values must match ^[A-Za-z0-9_-]{1,64}$.") + if name in names: + _fail(field, "Message tag names must be unique (case-sensitive).") + names.add(name) + + +def validate_email_message(message: object, prefix: str = "") -> None: + """Checks what the SDK can check before a request: the shape, tags and attachments.""" + + def at(field: str) -> str: + return f"{prefix}.{field}" if prefix else field + + if not isinstance(message, Mapping): + _fail(prefix or "message", "An email message must be a mapping.") + legacy_tag = message.get("tag") + if legacy_tag is not None and not isinstance(legacy_tag, str): + _fail(at("tag"), "The legacy tag must be a string.") + _validate_tags( + message.get("tags"), isinstance(legacy_tag, str) and legacy_tag != "", at("tags") + ) + attachments = message.get("attachments") + if attachments is not None: + if not isinstance(attachments, (list, tuple)): + _fail(at("attachments"), "Attachments must be a list.") + for index, attachment in enumerate(attachments): + field = f"{at('attachments')}[{index}]" + if ( + not isinstance(attachment, Mapping) + or not isinstance(attachment.get("filename"), str) + or not attachment["filename"] + ): + _fail(field, "An attachment needs a filename.") + content = attachment.get("content") + if not isinstance(content, str) and as_bytes(content) is None: + _fail(field, "Attachment content must be a base64 string or bytes.") + + +def _copy(value: Any) -> Any: + """Copies plain data (mappings, lists, scalars and bytes).""" + if isinstance(value, Mapping): + return {key: _copy(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_copy(item) for item in value] + binary = as_bytes(value) + return binary if binary is not None else copy.copy(value) + + +def to_wire(message: Mapping[str, Any]) -> SendMailRequest: + """A deep copy of the message in wire format, with bytes attachment content base64-encoded.""" + wire: dict[str, Any] = _copy(message) + if wire.get("attachments"): + wire["attachments"] = [ + { + **attachment, + "content": base64.b64encode(attachment["content"]).decode("ascii") + if isinstance(attachment["content"], bytes) + else attachment["content"], + } + for attachment in wire["attachments"] + ] + return wire # type: ignore[return-value] + + +def _summary(message: Mapping[str, Any]) -> dict[str, Any]: + """The message for ``repr()``: attachment content is summarized, not printed.""" + view = dict(message) + if view.get("attachments"): + view["attachments"] = [ + { + **attachment, + "content": f"<{len(attachment['content'])} {'bytes' if isinstance(attachment['content'], bytes) else 'base64 characters'}>", + } + for attachment in view["attachments"] + ] + return view + + +class BaseEmailBuilder: + """The immutable state and setters of :class:`EmailBuilder` and :class:`AsyncEmailBuilder`. + + Every setter returns a new builder and leaves this one unchanged, so a base + builder can be shared and reused safely, also across threads and tasks. + ``to``, ``cc``, ``bcc`` and ``reply_to`` replace their list; ``attach`` + appends. A setter that raises leaves the builder unchanged. + """ + + __slots__ = ("_emails", "_message") + + _emails: Any + _message: dict[str, Any] + + def __init__(self, emails: Any, message: Mapping[str, Any] | None = None) -> None: + object.__setattr__(self, "_emails", emails) + object.__setattr__( + self, + "_message", + dict(message) if message is not None else {"from": "", "to": [], "subject": ""}, + ) + + def __setattr__(self, name: str, value: Any) -> NoReturn: + raise AttributeError(f"{type(self).__name__} is immutable; setters return a new builder") + + def __delattr__(self, name: str) -> NoReturn: + raise AttributeError(f"{type(self).__name__} is immutable; setters return a new builder") + + def __reduce__(self) -> NoReturn: + raise TypeError(f"{type(self).__name__} cannot be pickled; pickle build() instead") + + def _with(self, **changes: Any) -> Self: + message = dict(self._message) + for key, value in changes.items(): + key = key.rstrip("_") + if value is None: + message.pop(key, None) + else: + message[key] = value + validate_email_message(message) + return type(self)(self._emails, message) + + def from_(self, address: str) -> Self: + """Sender, for example ``Acme ``. (``from`` is a Python keyword.)""" + return self._with(from_=address) + + def to(self, *addresses: str) -> Self: + """Replaces the recipients.""" + return self._with(to=list(addresses)) + + def cc(self, *addresses: str) -> Self: + """Replaces the CC recipients.""" + return self._with(cc=list(addresses)) + + def bcc(self, *addresses: str) -> Self: + """Replaces the BCC recipients.""" + return self._with(bcc=list(addresses)) + + def reply_to(self, *addresses: str) -> Self: + """Replaces the Reply-To addresses.""" + return self._with(reply_to=list(addresses)) + + def subject(self, subject: str) -> Self: + return self._with(subject=subject) + + def html(self, html: str | None) -> Self: + """HTML body. ``None`` removes it.""" + return self._with(html=html) + + def text(self, text: str | None) -> Self: + """Plain-text body. ``None`` removes it.""" + return self._with(text=text) + + def headers(self, headers: Mapping[str, str]) -> Self: + """Replaces the custom email headers.""" + return self._with(headers=dict(headers)) + + def metadata(self, metadata: Mapping[str, str]) -> Self: + """Replaces the metadata (stored with the email, not added as headers).""" + return self._with(metadata=dict(metadata)) + + def tag(self, tag: str | None) -> Self: + """The legacy single tag. ``None`` removes it.""" + return self._with(tag=tag) + + def tags(self, tags: Sequence[MessageTagInput]) -> Self: + """Replaces the name/value tags (up to 20, or 19 with a legacy ``tag``).""" + if not isinstance(tags, (list, tuple)): + _fail("tags", "Message tags must be a list of {name, value} mappings.") + return self._with(tags=[dict(tag) if isinstance(tag, Mapping) else tag for tag in tags]) + + def route(self, route: str) -> Self: + """The route slug to send through.""" + return self._with(route=route) + + def scheduled_at(self, when: str | datetime | None) -> Self: + """Schedules delivery: ISO 8601 or English text (``tomorrow 9am``), or an aware datetime. + + ``None`` removes it. A naive datetime raises + :class:`~lettermint.LettermintValidationError`, because its time zone is unknown. + """ + if isinstance(when, datetime): + if when.tzinfo is None or when.utcoffset() is None: + _fail("scheduled_at", "scheduled_at needs a timezone-aware datetime.") + when = when.isoformat() + return self._with(scheduled_at=when) + + def settings(self, settings: SendMailRequestSettings) -> Self: + """Per-email settings that override the route settings.""" + return self._with(settings=dict(settings)) + + def sandbox_result(self, result: SandboxResult) -> Self: + """The result a Sandbox project simulates for every recipient.""" + return self._with(sandbox_result=result) + + def attach( + self, + filename: str, + content: str | Buffer, + *, + content_type: str | None = None, + content_id: str | None = None, + ) -> Self: + """Adds an attachment. ``content`` is base64 text or raw bytes (encoded by the SDK).""" + attachment: dict[str, Any] = { + "filename": filename, + "content": content if isinstance(content, str) else (as_bytes(content) or content), + } + if content_type is not None: + attachment["content_type"] = content_type + if content_id is not None: + attachment["content_id"] = content_id + return self._with(attachments=[*self._message.get("attachments", []), attachment]) + + def build(self) -> SendMailRequest: + """A copy of the message in the API's wire format, with attachments base64-encoded.""" + return to_wire(self._message) + + def __repr__(self) -> str: + return f"{type(self).__name__}({_summary(self._message)!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BaseEmailBuilder): + return NotImplemented + return type(self) is type(other) and self._message == other._message + + __hash__ = None # type: ignore[assignment] + + +def check_message(message: object, prefix: str = "") -> SendMailRequest: + """Validates a message (or a builder) and returns its wire format.""" + if isinstance(message, BaseEmailBuilder): + return message.build() + validate_email_message(message, prefix) + return to_wire(message) # type: ignore[arg-type] + + +def initial_message(message: object) -> dict[str, Any]: + validate_email_message(message) + return _copy(message) # type: ignore[no-any-return] diff --git a/src/lettermint/_query.py b/src/lettermint/_query.py new file mode 100644 index 0000000..a44d724 --- /dev/null +++ b/src/lettermint/_query.py @@ -0,0 +1,83 @@ +"""Query string serialization in the API's bracket syntax. + +The output is byte-for-byte what the Node.js SDK sends: nested mappings use +brackets (``page[size]=30``), lists of scalars are comma-separated, lists of +mappings are indexed (``filter[tags][0][name]=a``), booleans are ``1``/``0`` +and ``None`` values are left out. Encoding follows the WHATWG +``application/x-www-form-urlencoded`` serializer (``URLSearchParams``). +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from datetime import date, datetime +from typing import Any +from urllib.parse import quote_plus + +_GROUP = re.compile(r"^([^\[\]]+)\[([^\[\]]+)\]$") + + +def _encode(text: str) -> str: + # URLSearchParams keeps ASCII alphanumerics and *-._, turns spaces into + + # and percent-encodes everything else (UTF-8), including ~. + return quote_plus(text, safe="*").replace("~", "%7E") + + +def _is_scalar(value: Any) -> bool: + return value is None or isinstance(value, (str, bytes, int, float, bool, date, datetime)) + + +def _scalar(value: Any) -> str: + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, (date, datetime)): + return value.isoformat() + if isinstance(value, bytes): + return value.decode("utf-8") + if isinstance(value, float) and value.is_integer() and abs(value) < 1e21: + return str(int(value)) # JavaScript's String(30.0) is "30" + return str(value) + + +def _append(pairs: list[tuple[str, str]], key: str, value: Any) -> None: + if value is None: + return + if isinstance(value, Mapping): + for name, item in value.items(): + _append(pairs, f"{key}[{name}]", item) + return + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + if all(_is_scalar(item) for item in value): + items = [_scalar(item) for item in value if item is not None] + if items: + pairs.append((key, ",".join(items))) + return + for index, item in enumerate(value): + _append(pairs, f"{key}[{index}]", item) + return + pairs.append((key, _scalar(value))) + + +def serialize_query(query: Mapping[str, Any] | None) -> str: + """``{"page": {"size": 10}, "sort": ["-created_at"]}`` -> ``page%5Bsize%5D=10&sort=-created_at``.""" + if not query: + return "" + pairs: list[tuple[str, str]] = [] + for key, value in query.items(): + _append(pairs, str(key), value) + return "&".join(f"{_encode(key)}={_encode(value)}" for key, value in pairs) + + +def with_query_param(query: Mapping[str, Any] | None, name: str, value: str) -> dict[str, Any]: + """A copy of ``query`` with the wire parameter ``name`` (``cursor`` or ``page[cursor]``) set.""" + base = dict(query or {}) + match = _GROUP.match(name) + if not match: + base[name] = value + return base + group, key = match.groups() + base.pop(name, None) + current = base.get(group) + base[group] = {**(dict(current) if isinstance(current, Mapping) else {}), key: value} + return base diff --git a/src/lettermint/_sync/__init__.py b/src/lettermint/_sync/__init__.py new file mode 100644 index 0000000..c0d6d2e --- /dev/null +++ b/src/lettermint/_sync/__init__.py @@ -0,0 +1,37 @@ +# Generated from src/lettermint/_async/__init__.py by scripts/unasync.py — do not edit. +"""The synchronous client, generated by ``scripts/unasync.py``.""" + +from .client import Lettermint +from .emails import EmailBuilder, Emails +from .resources import ( + Domains, + Messages, + Projects, + ReportForwarding, + Resource, + Routes, + Stats, + Suppressions, + Team, + TeamMembers, + WebhookDeliveries, + Webhooks, +) + +__all__ = [ + "Domains", + "EmailBuilder", + "Emails", + "Lettermint", + "Messages", + "Projects", + "ReportForwarding", + "Resource", + "Routes", + "Stats", + "Suppressions", + "Team", + "TeamMembers", + "WebhookDeliveries", + "Webhooks", +] diff --git a/src/lettermint/_sync/client.py b/src/lettermint/_sync/client.py new file mode 100644 index 0000000..65f56a5 --- /dev/null +++ b/src/lettermint/_sync/client.py @@ -0,0 +1,155 @@ +# Generated from src/lettermint/_async/client.py by scripts/unasync.py — do not edit. +"""The synchronous client, :class:`Lettermint`.""" + +from __future__ import annotations + +from types import TracebackType +from typing import Any, cast + +import httpx +from typing_extensions import Self + +from .._core import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, build_config, describe_client +from .._generated.types import AnalyticsQuery, AnalyticsResponse, BlockedFileTypes +from .._transport import Transport +from ..exceptions import LettermintConfigError +from .emails import Emails +from .resources import ( + Domains, + Messages, + Projects, + Routes, + Stats, + Suppressions, + Team, + Webhooks, +) + +__all__ = ["Lettermint"] + + +class Lettermint: + """The Lettermint client. + + :: + + lettermint = Lettermint(sending_token=..., team_token=...) + lettermint = Lettermint("lm_...") # team or sending token, detected by its prefix + + ``emails`` uses the sending token; every other part uses the team token. + The client holds no message state, so create it once and share it. Close + it when you are done, or use it as a context manager:: + + with Lettermint(sending_token=token) as lettermint: + lettermint.emails.send({...}) + """ + + #: Send email. Needs ``sending_token``. + emails: Emails + #: Sending domains. Needs ``team_token``. + domains: Domains + #: Sent and received messages. Needs ``team_token``. + messages: Messages + #: Projects and their report forwarding. Needs ``team_token``. + projects: Projects + #: Routes of a project. Needs ``team_token``. + routes: Routes + #: Sending statistics. Needs ``team_token``. + stats: Stats + #: The suppression list. Needs ``team_token``. + suppressions: Suppressions + #: The team and its members. Needs ``team_token``. + team: Team + #: Webhook endpoints and their deliveries. Needs ``team_token``. + webhooks: Webhooks + + def __init__( + self, + token: str | None = None, + /, + *, + sending_token: str | None = None, + team_token: str | None = None, + base_url: str = DEFAULT_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + http_client: httpx.Client | None = None, + ) -> None: + """Creates a client. + + Args: + token: A token string; ``lm_team_...`` is a team token, any other + ``lm_...`` token a sending token. Other formats raise + :class:`~lettermint.LettermintConfigError`. + sending_token: A project sending token, sent as ``x-lettermint-token``. + team_token: A team API token, sent as ``Authorization: Bearer``. + base_url: The API base URL. + timeout: Seconds for each request, covering the response headers and body. + http_client: An ``httpx`` client to send requests with, for example + for a proxy. The SDK never follows redirects and does not close + a client it did not create. + """ + config = build_config(token, sending_token, team_token, base_url, timeout) + if http_client is not None and not isinstance(http_client, httpx.Client): + raise LettermintConfigError("http_client must be an httpx.Client.") + client = http_client or httpx.Client( + follow_redirects=False, timeout=httpx.Timeout(config.timeout) + ) + self._transport = Transport(config, client, owns_client=http_client is None) + self.emails = Emails(self._transport) + self.domains = Domains(self._transport) + self.messages = Messages(self._transport) + self.projects = Projects(self._transport) + self.routes = Routes(self._transport) + self.stats = Stats(self._transport) + self.suppressions = Suppressions(self._transport) + self.team = Team(self._transport) + self.webhooks = Webhooks(self._transport) + + def ping(self, *, timeout: float | None = None) -> str: + """Checks the token: ``GET /ping`` returns ``pong``. + + Uses the team token when it is set, otherwise the sending token. + """ + text = self._transport.request("GET /ping", label="ping", timeout=timeout) + return cast(str, text).strip() + + def analytics( + self, query: AnalyticsQuery, *, timeout: float | None = None + ) -> AnalyticsResponse: + """Queries email analytics. Needs ``team_token``.""" + return cast( + AnalyticsResponse, + self._transport.request( + "POST /analytics", label="analytics", body=query, timeout=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( + BlockedFileTypes, + self._transport.request( + "GET /blocked-file-types", label="blocked_file_types", timeout=timeout + ), + ) + + def close(self) -> None: + """Closes the HTTP client, if the SDK created it. Later calls raise ``LettermintConfigError``.""" + self._transport.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + def __repr__(self) -> str: + return describe_client(type(self).__name__, self._transport.config) + + def __reduce__(self) -> Any: + raise TypeError(f"{type(self).__name__} cannot be pickled") diff --git a/src/lettermint/_sync/emails.py b/src/lettermint/_sync/emails.py new file mode 100644 index 0000000..2dd8387 --- /dev/null +++ b/src/lettermint/_sync/emails.py @@ -0,0 +1,102 @@ +# Generated from src/lettermint/_async/emails.py by scripts/unasync.py — do not edit. +"""Sending email: :class:`Emails` and the immutable :class:`EmailBuilder`.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, cast + +from .._core import auth_header +from .._emails import BaseEmailBuilder, EmailMessage, check_message, initial_message +from .._generated.types import SendBatchMailResponse, SendMailResponse +from ..exceptions import LettermintValidationError +from .resources import Resource + +__all__ = ["EmailBuilder", "Emails"] + + +class EmailBuilder(BaseEmailBuilder): + """An immutable email builder, created by ``lettermint.emails.compose()``. + + Every setter returns a new builder and leaves this one unchanged, so a base + builder can be shared and reused safely:: + + welcome = lettermint.emails.compose().from_("Acme ").subject("Welcome") + welcome.to("jane@example.com").html("

Hi Jane

").send() + """ + + __slots__ = () + + def send( + self, *, idempotency_key: str | None = None, timeout: float | None = None + ) -> SendMailResponse: + """Sends a snapshot of this email. The builder is unchanged and can be sent again.""" + emails = cast(Emails, self._emails) + return emails.send( + cast(EmailMessage, self._message), idempotency_key=idempotency_key, timeout=timeout + ) + + +class Emails(Resource): + """Sends email with the project sending token (``x-lettermint-token``). + + Holds no message state: every call sends exactly what it is given. + """ + + def send( + self, + message: EmailMessage, + *, + idempotency_key: str | None = None, + timeout: float | None = None, + ) -> SendMailResponse: + """Sends one email, given in the API's wire format (``reply_to``, ``scheduled_at``, ...).""" + auth_header(self._transport.config, "emails.send", "sending") + body = check_message(message) + return cast( + SendMailResponse, + self._request( + "POST /send", + "emails.send", + body=body, + idempotency_key=idempotency_key, + timeout=timeout, + ), + ) + + def send_batch( + self, + messages: Sequence[EmailMessage | EmailBuilder], + *, + idempotency_key: str | None = None, + timeout: float | None = None, + ) -> SendBatchMailResponse: + """Sends up to 500 emails in one request. Accepts messages and builders.""" + auth_header(self._transport.config, "emails.send_batch", "sending") + if not isinstance(messages, (list, tuple)): + raise LettermintValidationError( + "send_batch() takes a list of messages.", field="messages" + ) + body: list[Any] = [ + check_message(message, f"messages[{index}]") for index, message in enumerate(messages) + ] + return cast( + SendBatchMailResponse, + self._request( + "POST /send/batch", + "emails.send_batch", + body=body, + idempotency_key=idempotency_key, + timeout=timeout, + ), + ) + + def compose(self, message: EmailMessage | None = None) -> EmailBuilder: + """Starts an immutable email builder, empty or from ``message``.""" + auth_header(self._transport.config, "emails.compose", "sending") + return EmailBuilder(self, None if message is None else initial_message(message)) + + def ping(self, *, timeout: float | None = None) -> str: + """Checks the sending token: ``GET /ping`` returns ``pong``.""" + text = self._request("GET /ping", "emails.ping", auth="sending", timeout=timeout) + return cast(str, text).strip() diff --git a/src/lettermint/_sync/resources.py b/src/lettermint/_sync/resources.py new file mode 100644 index 0000000..43e71ec --- /dev/null +++ b/src/lettermint/_sync/resources.py @@ -0,0 +1,956 @@ +# Generated from src/lettermint/_async/resources.py by scripts/unasync.py — do not edit. +"""The Team API sub-clients of :class:`~lettermint.Lettermint`. + +Every method uses the team token, except ``messages.reschedule`` and +``messages.cancel``, which use the team token when it is set and otherwise +the sending token. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator, Mapping +from typing import Any, cast + +from .._generated.operations import OPERATIONS +from .._generated.types import ( + DeleteSuppressionResponse, + DnsVerificationSuccessResponse, + DomainData, + DomainListData, + DomainMutationResponse, + GetDomainQuery, + GetProjectQuery, + GetReportForwardingResponse, + GetRouteQuery, + GetStatsQuery, + GetTeamQuery, + InboundDomainVerificationResponse, + ListDomainsQuery, + ListDomainsResponse, + ListMessageEventsQuery, + ListMessageEventsResponse, + ListMessagesQuery, + ListMessagesResponse, + ListProjectsQuery, + ListProjectsResponse, + ListRoutesQuery, + ListRoutesResponse, + ListSuppressionsQuery, + ListSuppressionsResponse, + ListTeamMembersQuery, + ListTeamMembersResponse, + ListWebhookDeliveriesQuery, + ListWebhookDeliveriesResponse, + ListWebhooksQuery, + ListWebhooksResponse, + MessageData, + MessageEventData, + MessageListData, + MessageResponse, + ProcessInboundMessageResponse, + ProjectCreatedData, + ProjectData, + ProjectListData, + ProjectMutationResponse, + ReportForwardingRequest, + RescheduleMessageRequest, + ResendReportForwardingCodeResponse, + RotateProjectTokenResponse, + RouteData, + RouteListData, + RouteMutationResponse, + ScheduledMessage, + StatsData, + StoreDomainData, + StoreProjectData, + StoreRouteData, + StoreSuppressionData, + StoreWebhookData, + SuppressedRecipientData, + SuppressionStoreResponse, + TeamData, + TeamMemberData, + TeamMutationResponse, + TeamRoleListResponse, + TeamUsageDetailData, + TestWebhookResponse, + UpdateDomainProjectsData, + UpdateProjectData, + UpdateReportForwardingResponse, + UpdateRouteData, + UpdateTeamData, + UpdateTeamMemberAssignmentData, + UpdateWebhookData, + VerifyReportForwardingRequest, + VerifyReportForwardingResponse, + WebhookData, + WebhookDeliveryData, + WebhookDeliveryListData, + WebhookListData, + WebhookMutationResponse, + WebhookSecretResponse, +) +from .._query import with_query_param +from .._transport import Transport +from ..exceptions import UnexpectedResponseError + +__all__ = [ + "Domains", + "Messages", + "Projects", + "ReportForwarding", + "Routes", + "Stats", + "Suppressions", + "Team", + "TeamMembers", + "WebhookDeliveries", + "Webhooks", +] + + +def _paginate( + transport: Transport, + key: str, + label: str, + path: Mapping[str, str] | None, + query: Mapping[str, Any] | None, + timeout: float | None, +) -> Iterator[Any]: + """Follows ``next_cursor`` until it is ``None``, or repeats a cursor.""" + pagination = OPERATIONS[key].pagination + assert pagination is not None and pagination.cursor_param is not None + seen: set[str] = set() + current = query + while True: + page = transport.request(key, label=label, path=path, query=current, timeout=timeout) + data = page.get("data") if isinstance(page, dict) else None + if not isinstance(data, list): + raise UnexpectedResponseError( + f"{label}: the API returned a page without a data array.", + status=200, + body=json.dumps(page), + ) + for item in data: + yield item + cursor = page.get("next_cursor") + if not isinstance(cursor, str) or not cursor or cursor in seen: + return + seen.add(cursor) + current = with_query_param(query, pagination.cursor_param, cursor) + + +class Resource: + """Base of the sub-clients. ``repr()`` shows no configuration and no credentials.""" + + def __init__(self, transport: Transport) -> None: + self._transport = transport + + def _request(self, key: str, label: str, **arguments: Any) -> Any: + return self._transport.request(key, label=label, **arguments) + + def _paginate( + self, + key: str, + label: str, + *, + path: Mapping[str, str] | None = None, + query: Mapping[str, Any] | None = None, + timeout: float | None = None, + ) -> Iterator[Any]: + # Check the token, path parameters and options now, not at the first page. + self._transport.prepare(key, label=label, path=path, query=query, timeout=timeout) + return _paginate(self._transport, key, label, path, query, timeout) + + def __repr__(self) -> str: + return f"{type(self).__name__}()" + + def __reduce__(self) -> Any: + raise TypeError(f"{type(self).__name__} cannot be pickled") + + +class Domains(Resource): + """Sending domains. Team token.""" + + def list( + self, query: ListDomainsQuery | None = None, *, timeout: float | None = None + ) -> ListDomainsResponse: + """Lists domains, one page at a time.""" + return cast( + ListDomainsResponse, + self._request("GET /domains", "domains.list", query=query, timeout=timeout), + ) + + def iterate( + self, query: ListDomainsQuery | None = None, *, timeout: float | None = None + ) -> Iterator[DomainListData]: + """Iterates over every domain, following ``next_cursor``.""" + return self._paginate("GET /domains", "domains.iterate", query=query, timeout=timeout) + + def create(self, body: StoreDomainData, *, timeout: float | None = None) -> DomainData: + return cast( + DomainData, + self._request("POST /domains", "domains.create", body=body, timeout=timeout), + ) + + def retrieve( + self, domain_id: str, query: GetDomainQuery | None = None, *, timeout: float | None = None + ) -> DomainData: + return cast( + DomainData, + self._request( + "GET /domains/{domainId}", + "domains.retrieve", + path={"domainId": domain_id}, + query=query, + timeout=timeout, + ), + ) + + def delete(self, domain_id: str, *, timeout: float | None = None) -> MessageResponse: + return cast( + MessageResponse, + self._request( + "DELETE /domains/{domainId}", + "domains.delete", + path={"domainId": domain_id}, + timeout=timeout, + ), + ) + + def verify_dns_records( + self, domain_id: str, *, timeout: float | None = None + ) -> DnsVerificationSuccessResponse: + """Checks every DNS record of the domain.""" + return cast( + DnsVerificationSuccessResponse, + self._request( + "POST /domains/{domainId}/dns-records/verify", + "domains.verify_dns_records", + path={"domainId": domain_id}, + timeout=timeout, + ), + ) + + def verify_dns_record( + self, domain_id: str, record_id: str, *, timeout: float | None = None + ) -> MessageResponse: + """Checks one DNS record of the domain.""" + return cast( + MessageResponse, + self._request( + "POST /domains/{domainId}/dns-records/{recordId}/verify", + "domains.verify_dns_record", + path={"domainId": domain_id, "recordId": record_id}, + timeout=timeout, + ), + ) + + def update_projects( + self, domain_id: str, body: UpdateDomainProjectsData, *, timeout: float | None = None + ) -> DomainMutationResponse: + """Replaces the projects that may send from the domain.""" + return cast( + DomainMutationResponse, + self._request( + "PUT /domains/{domainId}/projects", + "domains.update_projects", + path={"domainId": domain_id}, + body=body, + timeout=timeout, + ), + ) + + +class Messages(Resource): + """Sent and received messages. Team token. + + ``reschedule`` and ``cancel`` also accept the sending token when no team + token is configured. + """ + + def list( + self, query: ListMessagesQuery | None = None, *, timeout: float | None = None + ) -> ListMessagesResponse: + """Lists messages, one page at a time.""" + return cast( + ListMessagesResponse, + self._request("GET /messages", "messages.list", query=query, timeout=timeout), + ) + + def iterate( + self, query: ListMessagesQuery | None = None, *, timeout: float | None = None + ) -> Iterator[MessageListData]: + """Iterates over every message, following ``next_cursor``.""" + return self._paginate("GET /messages", "messages.iterate", query=query, timeout=timeout) + + def retrieve(self, message_id: str, *, timeout: float | None = None) -> MessageData: + return cast( + MessageData, + self._request( + "GET /messages/{messageId}", + "messages.retrieve", + path={"messageId": message_id}, + timeout=timeout, + ), + ) + + def events( + self, + message_id: str, + query: ListMessageEventsQuery | None = None, + *, + timeout: float | None = None, + ) -> ListMessageEventsResponse: + """Lists the events of a message, one page at a time.""" + return cast( + ListMessageEventsResponse, + self._request( + "GET /messages/{messageId}/events", + "messages.events", + path={"messageId": message_id}, + query=query, + timeout=timeout, + ), + ) + + def iterate_events( + self, + message_id: str, + query: ListMessageEventsQuery | None = None, + *, + timeout: float | None = None, + ) -> Iterator[MessageEventData]: + """Iterates over every event of a message, following ``next_cursor``.""" + return self._paginate( + "GET /messages/{messageId}/events", + "messages.iterate_events", + path={"messageId": message_id}, + query=query, + timeout=timeout, + ) + + def source(self, message_id: str, *, timeout: float | None = None) -> str: + """The raw RFC 822 source.""" + return cast( + str, + self._request( + "GET /messages/{messageId}/source", + "messages.source", + path={"messageId": message_id}, + timeout=timeout, + ), + ) + + def html(self, message_id: str, *, timeout: float | None = None) -> str: + """The HTML body.""" + return cast( + str, + self._request( + "GET /messages/{messageId}/html", + "messages.html", + path={"messageId": message_id}, + timeout=timeout, + ), + ) + + def text(self, message_id: str, *, timeout: float | None = None) -> str: + """The plain-text body.""" + return cast( + str, + self._request( + "GET /messages/{messageId}/text", + "messages.text", + path={"messageId": message_id}, + timeout=timeout, + ), + ) + + def reschedule( + self, message_id: str, body: RescheduleMessageRequest, *, timeout: float | None = None + ) -> ScheduledMessage: + """Moves a scheduled message to another delivery time.""" + return cast( + ScheduledMessage, + self._request( + "PATCH /messages/{messageId}", + "messages.reschedule", + path={"messageId": message_id}, + body=body, + timeout=timeout, + ), + ) + + def cancel(self, message_id: str, *, timeout: float | None = None) -> ScheduledMessage: + """Cancels a scheduled message.""" + return cast( + ScheduledMessage, + self._request( + "POST /messages/{messageId}/cancel", + "messages.cancel", + path={"messageId": message_id}, + timeout=timeout, + ), + ) + + def process( + self, + message_id: str, + *, + idempotency_key: str | None = None, + timeout: float | None = None, + ) -> ProcessInboundMessageResponse: + """Releases one quarantined inbound message for webhook delivery.""" + return cast( + ProcessInboundMessageResponse, + self._request( + "POST /messages/{messageId}/process", + "messages.process", + path={"messageId": message_id}, + idempotency_key=idempotency_key, + timeout=timeout, + ), + ) + + +class ReportForwarding(Resource): + """DMARC and complaint report forwarding of a project. Team token.""" + + def retrieve( + self, project_id: str, *, timeout: float | None = None + ) -> GetReportForwardingResponse: + return cast( + GetReportForwardingResponse, + self._request( + "GET /projects/{projectId}/report-forwarding", + "projects.report_forwarding.retrieve", + path={"projectId": project_id}, + timeout=timeout, + ), + ) + + def update( + self, project_id: str, body: ReportForwardingRequest, *, timeout: float | None = None + ) -> UpdateReportForwardingResponse: + return cast( + UpdateReportForwardingResponse, + self._request( + "PUT /projects/{projectId}/report-forwarding", + "projects.report_forwarding.update", + path={"projectId": project_id}, + body=body, + timeout=timeout, + ), + ) + + def delete(self, project_id: str, *, timeout: float | None = None) -> None: + """Disables report forwarding (HTTP 204).""" + self._request( + "DELETE /projects/{projectId}/report-forwarding", + "projects.report_forwarding.delete", + path={"projectId": project_id}, + timeout=timeout, + ) + + def verify( + self, project_id: str, body: VerifyReportForwardingRequest, *, timeout: float | None = None + ) -> VerifyReportForwardingResponse: + return cast( + VerifyReportForwardingResponse, + self._request( + "POST /projects/{projectId}/report-forwarding/verify", + "projects.report_forwarding.verify", + path={"projectId": project_id}, + body=body, + timeout=timeout, + ), + ) + + def resend_code( + self, project_id: str, *, timeout: float | None = None + ) -> ResendReportForwardingCodeResponse: + return cast( + ResendReportForwardingCodeResponse, + self._request( + "POST /projects/{projectId}/report-forwarding/resend-code", + "projects.report_forwarding.resend_code", + path={"projectId": project_id}, + timeout=timeout, + ), + ) + + +class Projects(Resource): + """Projects. Team token.""" + + def __init__(self, transport: Transport) -> None: + super().__init__(transport) + #: Report forwarding of a project. + self.report_forwarding = ReportForwarding(transport) + + def list( + self, query: ListProjectsQuery | None = None, *, timeout: float | None = None + ) -> ListProjectsResponse: + """Lists projects, one page at a time.""" + return cast( + ListProjectsResponse, + self._request("GET /projects", "projects.list", query=query, timeout=timeout), + ) + + def iterate( + self, query: ListProjectsQuery | None = None, *, timeout: float | None = None + ) -> Iterator[ProjectListData]: + """Iterates over every project, following ``next_cursor``.""" + return self._paginate("GET /projects", "projects.iterate", query=query, timeout=timeout) + + def create( + self, body: StoreProjectData, *, timeout: float | None = None + ) -> ProjectCreatedData: + """Creates a project. The response holds its sending token once (``api_token``).""" + return cast( + ProjectCreatedData, + self._request("POST /projects", "projects.create", body=body, timeout=timeout), + ) + + def retrieve( + self, project_id: str, query: GetProjectQuery | None = None, *, timeout: float | None = None + ) -> ProjectData: + return cast( + ProjectData, + self._request( + "GET /projects/{projectId}", + "projects.retrieve", + path={"projectId": project_id}, + query=query, + timeout=timeout, + ), + ) + + def update( + self, project_id: str, body: UpdateProjectData, *, timeout: float | None = None + ) -> ProjectMutationResponse: + return cast( + ProjectMutationResponse, + self._request( + "PUT /projects/{projectId}", + "projects.update", + path={"projectId": project_id}, + body=body, + timeout=timeout, + ), + ) + + def delete(self, project_id: str, *, timeout: float | None = None) -> MessageResponse: + return cast( + MessageResponse, + self._request( + "DELETE /projects/{projectId}", + "projects.delete", + path={"projectId": project_id}, + timeout=timeout, + ), + ) + + def rotate_token( + self, project_id: str, *, timeout: float | None = None + ) -> RotateProjectTokenResponse: + """Rotates the project's legacy sending token. Deprecated by the API.""" + return cast( + RotateProjectTokenResponse, + self._request( + "POST /projects/{projectId}/rotate-token", + "projects.rotate_token", + path={"projectId": project_id}, + timeout=timeout, + ), + ) + + +class Routes(Resource): + """Routes of a project. Team token.""" + + def list( + self, project_id: str, query: ListRoutesQuery | None = None, *, timeout: float | None = None + ) -> ListRoutesResponse: + """Lists the routes of a project, one page at a time.""" + return cast( + ListRoutesResponse, + self._request( + "GET /projects/{projectId}/routes", + "routes.list", + path={"projectId": project_id}, + query=query, + timeout=timeout, + ), + ) + + def iterate( + self, project_id: str, query: ListRoutesQuery | None = None, *, timeout: float | None = None + ) -> Iterator[RouteListData]: + """Iterates over every route of a project, following ``next_cursor``.""" + return self._paginate( + "GET /projects/{projectId}/routes", + "routes.iterate", + path={"projectId": project_id}, + query=query, + timeout=timeout, + ) + + def create( + self, project_id: str, body: StoreRouteData, *, timeout: float | None = None + ) -> RouteMutationResponse: + return cast( + RouteMutationResponse, + self._request( + "POST /projects/{projectId}/routes", + "routes.create", + path={"projectId": project_id}, + body=body, + timeout=timeout, + ), + ) + + def retrieve( + self, route_id: str, query: GetRouteQuery | None = None, *, timeout: float | None = None + ) -> RouteData: + return cast( + RouteData, + self._request( + "GET /routes/{routeId}", + "routes.retrieve", + path={"routeId": route_id}, + query=query, + timeout=timeout, + ), + ) + + def update( + self, route_id: str, body: UpdateRouteData, *, timeout: float | None = None + ) -> RouteMutationResponse: + return cast( + RouteMutationResponse, + self._request( + "PUT /routes/{routeId}", + "routes.update", + path={"routeId": route_id}, + body=body, + timeout=timeout, + ), + ) + + def delete(self, route_id: str, *, timeout: float | None = None) -> MessageResponse: + return cast( + MessageResponse, + self._request( + "DELETE /routes/{routeId}", + "routes.delete", + path={"routeId": route_id}, + timeout=timeout, + ), + ) + + def verify_inbound_domain( + self, route_id: str, *, timeout: float | None = None + ) -> InboundDomainVerificationResponse: + return cast( + InboundDomainVerificationResponse, + self._request( + "POST /routes/{routeId}/verify-inbound-domain", + "routes.verify_inbound_domain", + path={"routeId": route_id}, + timeout=timeout, + ), + ) + + +class Stats(Resource): + """Sending statistics. Team token.""" + + def retrieve(self, query: GetStatsQuery, *, timeout: float | None = None) -> StatsData: + """Daily statistics between ``from`` and ``to`` (``YYYY-MM-DD``, at most 90 days).""" + return cast( + StatsData, + self._request("GET /stats", "stats.retrieve", query=query, timeout=timeout), + ) + + +class Suppressions(Resource): + """The suppression list. Team token.""" + + def list( + self, query: ListSuppressionsQuery | None = None, *, timeout: float | None = None + ) -> ListSuppressionsResponse: + """Lists suppressions, one page at a time.""" + return cast( + ListSuppressionsResponse, + self._request( + "GET /suppressions", "suppressions.list", query=query, timeout=timeout + ), + ) + + def iterate( + self, query: ListSuppressionsQuery | None = None, *, timeout: float | None = None + ) -> Iterator[SuppressedRecipientData]: + """Iterates over every suppression, following ``next_cursor``.""" + return self._paginate( + "GET /suppressions", "suppressions.iterate", query=query, timeout=timeout + ) + + def create( + self, body: StoreSuppressionData, *, timeout: float | None = None + ) -> SuppressionStoreResponse: + return cast( + SuppressionStoreResponse, + self._request( + "POST /suppressions", "suppressions.create", body=body, timeout=timeout + ), + ) + + def delete( + self, suppression_id: str, *, timeout: float | None = None + ) -> DeleteSuppressionResponse: + return cast( + DeleteSuppressionResponse, + self._request( + "DELETE /suppressions/{suppressionId}", + "suppressions.delete", + path={"suppressionId": suppression_id}, + timeout=timeout, + ), + ) + + +class TeamMembers(Resource): + """Team members. Team token.""" + + def list( + self, query: ListTeamMembersQuery | None = None, *, timeout: float | None = None + ) -> ListTeamMembersResponse: + """Lists team members, one page at a time.""" + return cast( + ListTeamMembersResponse, + self._request( + "GET /team/members", "team.members.list", query=query, timeout=timeout + ), + ) + + def iterate( + self, query: ListTeamMembersQuery | None = None, *, timeout: float | None = None + ) -> Iterator[TeamMemberData]: + """Iterates over every team member, following ``next_cursor``.""" + return self._paginate( + "GET /team/members", "team.members.iterate", query=query, timeout=timeout + ) + + def retrieve(self, user_id: str, *, timeout: float | None = None) -> TeamMemberData: + return cast( + TeamMemberData, + self._request( + "GET /team/members/{userId}", + "team.members.retrieve", + path={"userId": user_id}, + timeout=timeout, + ), + ) + + def update_assignment( + self, user_id: str, body: UpdateTeamMemberAssignmentData, *, timeout: float | None = None + ) -> TeamMemberData: + """Changes a member's role and project access.""" + return cast( + TeamMemberData, + self._request( + "PUT /team/members/{userId}/assignment", + "team.members.update_assignment", + path={"userId": user_id}, + body=body, + timeout=timeout, + ), + ) + + +class Team(Resource): + """The team of the token. Team token.""" + + def __init__(self, transport: Transport) -> None: + super().__init__(transport) + #: Team members. + self.members = TeamMembers(transport) + + def retrieve( + self, query: GetTeamQuery | None = None, *, timeout: float | None = None + ) -> TeamData: + return cast( + TeamData, + self._request("GET /team", "team.retrieve", query=query, timeout=timeout), + ) + + def update( + self, body: UpdateTeamData, *, timeout: float | None = None + ) -> TeamMutationResponse: + return cast( + TeamMutationResponse, + self._request("PUT /team", "team.update", body=body, timeout=timeout), + ) + + def usage(self, *, timeout: float | None = None) -> TeamUsageDetailData: + """Usage of the current and previous billing periods.""" + return cast( + TeamUsageDetailData, + self._request("GET /team/usage", "team.usage", timeout=timeout), + ) + + def roles(self, *, timeout: float | None = None) -> TeamRoleListResponse: + """The roles that can be assigned to members.""" + return cast( + TeamRoleListResponse, + self._request("GET /team/roles", "team.roles", timeout=timeout), + ) + + +class WebhookDeliveries(Resource): + """Delivery attempts of a webhook. Team token.""" + + def list( + self, + webhook_id: str, + query: ListWebhookDeliveriesQuery | None = None, + *, + timeout: float | None = None, + ) -> ListWebhookDeliveriesResponse: + """Lists the deliveries of a webhook, one page at a time.""" + return cast( + ListWebhookDeliveriesResponse, + self._request( + "GET /webhooks/{webhookId}/deliveries", + "webhooks.deliveries.list", + path={"webhookId": webhook_id}, + query=query, + timeout=timeout, + ), + ) + + def iterate( + self, + webhook_id: str, + query: ListWebhookDeliveriesQuery | None = None, + *, + timeout: float | None = None, + ) -> Iterator[WebhookDeliveryListData]: + """Iterates over every delivery of a webhook, following ``next_cursor``.""" + return self._paginate( + "GET /webhooks/{webhookId}/deliveries", + "webhooks.deliveries.iterate", + path={"webhookId": webhook_id}, + query=query, + timeout=timeout, + ) + + def retrieve( + self, webhook_id: str, delivery_id: str, *, timeout: float | None = None + ) -> WebhookDeliveryData: + return cast( + WebhookDeliveryData, + self._request( + "GET /webhooks/{webhookId}/deliveries/{deliveryId}", + "webhooks.deliveries.retrieve", + path={"webhookId": webhook_id, "deliveryId": delivery_id}, + timeout=timeout, + ), + ) + + +class Webhooks(Resource): + """Webhook endpoints. Team token. To verify incoming deliveries, use :class:`~lettermint.Webhook`.""" + + def __init__(self, transport: Transport) -> None: + super().__init__(transport) + #: Delivery attempts of a webhook. + self.deliveries = WebhookDeliveries(transport) + + def list( + self, query: ListWebhooksQuery | None = None, *, timeout: float | None = None + ) -> ListWebhooksResponse: + """Lists webhooks, one page at a time.""" + return cast( + ListWebhooksResponse, + self._request("GET /webhooks", "webhooks.list", query=query, timeout=timeout), + ) + + def iterate( + self, query: ListWebhooksQuery | None = None, *, timeout: float | None = None + ) -> Iterator[WebhookListData]: + """Iterates over every webhook, following ``next_cursor``.""" + return self._paginate("GET /webhooks", "webhooks.iterate", query=query, timeout=timeout) + + def create( + self, body: StoreWebhookData, *, timeout: float | None = None + ) -> WebhookSecretResponse: + """Creates a webhook. The response holds its signing secret once.""" + return cast( + WebhookSecretResponse, + self._request("POST /webhooks", "webhooks.create", body=body, timeout=timeout), + ) + + def retrieve(self, webhook_id: str, *, timeout: float | None = None) -> WebhookData: + return cast( + WebhookData, + self._request( + "GET /webhooks/{webhookId}", + "webhooks.retrieve", + path={"webhookId": webhook_id}, + timeout=timeout, + ), + ) + + def update( + self, webhook_id: str, body: UpdateWebhookData, *, timeout: float | None = None + ) -> WebhookMutationResponse: + return cast( + WebhookMutationResponse, + self._request( + "PUT /webhooks/{webhookId}", + "webhooks.update", + path={"webhookId": webhook_id}, + body=body, + timeout=timeout, + ), + ) + + def delete(self, webhook_id: str, *, timeout: float | None = None) -> MessageResponse: + return cast( + MessageResponse, + self._request( + "DELETE /webhooks/{webhookId}", + "webhooks.delete", + path={"webhookId": webhook_id}, + timeout=timeout, + ), + ) + + def test(self, webhook_id: str, *, timeout: float | None = None) -> TestWebhookResponse: + """Sends a ``webhook.test`` delivery.""" + return cast( + TestWebhookResponse, + self._request( + "POST /webhooks/{webhookId}/test", + "webhooks.test", + path={"webhookId": webhook_id}, + timeout=timeout, + ), + ) + + def regenerate_secret( + self, webhook_id: str, *, timeout: float | None = None + ) -> WebhookSecretResponse: + """Replaces the signing secret. The response holds the new secret once.""" + return cast( + WebhookSecretResponse, + self._request( + "POST /webhooks/{webhookId}/regenerate-secret", + "webhooks.regenerate_secret", + path={"webhookId": webhook_id}, + timeout=timeout, + ), + ) diff --git a/src/lettermint/_transport.py b/src/lettermint/_transport.py new file mode 100644 index 0000000..ed3d0b0 --- /dev/null +++ b/src/lettermint/_transport.py @@ -0,0 +1,213 @@ +"""The two I/O shells: :class:`Transport` (threads) and :class:`AsyncTransport` (AnyIO). + +Both send a prepared :class:`~lettermint._core.Call` and decode the answer +with the shared core. They never follow redirects and never retry. The +timeout covers the whole exchange, including the body. + +Exceptions of the HTTP library are translated into SDK exceptions *outside* +the ``except`` block, so the SDK exception has no ``__context__`` or +``__cause__`` that holds the request and its headers. +""" + +from __future__ import annotations + +import contextvars +import threading +from typing import Any + +import anyio +import httpx + +from ._core import Call, Config, decode, prepare, scrub +from .exceptions import ( + APIConnectionError, + APITimeoutError, + LettermintConfigError, + LettermintError, + RedirectError, +) + +__all__ = ["AsyncTransport", "Transport"] + +_Outcome = tuple[int, str, dict[str, str], bytes] + + +def _translate(error: BaseException, call: Call, config: Config) -> LettermintError | None: + """The SDK exception for an HTTP library exception, or ``None`` for anything else.""" + if isinstance(error, httpx.TimeoutException): + return APITimeoutError(call.timeout) + if isinstance(error, (httpx.HTTPError, httpx.InvalidURL)): + detail = scrub(str(error), config) + return APIConnectionError( + f"{call.label}: could not reach the Lettermint API ({type(error).__name__}" + + (f": {detail})" if detail else ")") + ) + if isinstance(error, RuntimeError) and "closed" in str(error): + return LettermintConfigError(f"{call.label}: the HTTP client is closed.") + return None + + +def _headers(response: httpx.Response) -> dict[str, str]: + return {key.lower(): value for key, value in response.headers.items()} + + +class _Base: + def __init__(self, config: Config, owns_client: bool) -> None: + self.config = config + self._owns_client = owns_client + self._closed = False + + def prepare(self, key: str, **arguments: Any) -> Call: + call = prepare(self.config, key, **arguments) + if self._closed: + raise LettermintConfigError(f"{call.label}: the client is closed.") + return call + + def _request(self, client: httpx.Client | httpx.AsyncClient, call: Call) -> httpx.Request: + # The only place where the credential leaves its Secret; the header dict + # does not outlive this frame. + return client.build_request( + call.method, + call.url, + headers=call.request_headers(), + content=call.content, + timeout=httpx.Timeout(call.timeout), + ) + + def __repr__(self) -> str: + return f"{type(self).__name__}()" + + def __reduce__(self) -> Any: + raise TypeError("Lettermint transports cannot be pickled") + + +class Transport(_Base): + """Sends requests with an ``httpx.Client``. + + The exchange runs on a worker thread so that the timeout covers the whole + request, including a body that arrives slowly: the caller stops waiting + at the deadline and the worker stops reading. ``httpx``'s own per-phase + timeouts, set to the same value, bound the abandoned worker. + """ + + def __init__(self, config: Config, client: httpx.Client, owns_client: bool) -> None: + super().__init__(config, owns_client) + self._client = client + + def request(self, key: str, **arguments: Any) -> Any: + call = self.prepare(key, **arguments) + status, reason, headers, body = self._exchange(call) + return decode(call, status, reason, headers, body) + + def _exchange(self, call: Call) -> _Outcome: + request = self._request(self._client, call) + client = self._client + box: list[_Outcome | BaseException] = [] + done = threading.Event() + abandoned = threading.Event() + + def work() -> None: + try: + response = client.send(request, stream=True, follow_redirects=False) + try: + chunks: list[bytes] = [] + if not 300 <= response.status_code < 400: + for chunk in response.iter_bytes(): + if abandoned.is_set(): + return + chunks.append(chunk) + box.append( + ( + response.status_code, + response.reason_phrase, + _headers(response), + b"".join(chunks), + ) + ) + finally: + response.close() + except BaseException as error: # handed to the caller's thread + box.append(error) + finally: + done.set() + + worker = threading.Thread( + target=contextvars.copy_context().run, + args=(work,), + name="lettermint-request", + daemon=True, + ) + worker.start() + if not done.wait(call.timeout) or not box: + abandoned.set() + raise APITimeoutError(call.timeout) + outcome = box[0] + if isinstance(outcome, BaseException): + translated = _translate(outcome, call, self.config) + if translated is None: + raise outcome + raise translated + return _check_redirect(outcome) + + def close(self) -> None: + self._closed = True + if self._owns_client: + self._client.close() + + +class AsyncTransport(_Base): + """Sends requests with an ``httpx.AsyncClient``, under an AnyIO deadline. + + Works with asyncio and trio. Cancelling the calling task cancels the + request; the SDK does not catch the cancellation. + """ + + def __init__(self, config: Config, client: httpx.AsyncClient, owns_client: bool) -> None: + super().__init__(config, owns_client) + self._client = client + + async def request(self, key: str, **arguments: Any) -> Any: + call = self.prepare(key, **arguments) + status, reason, headers, body = await self._exchange(call) + return decode(call, status, reason, headers, body) + + async def _exchange(self, call: Call) -> _Outcome: + request = self._request(self._client, call) + failure: LettermintError | None = None + outcome: _Outcome | None = None + try: + with anyio.fail_after(call.timeout): + response = await self._client.send(request, stream=True, follow_redirects=False) + try: + body = b"" + if not 300 <= response.status_code < 400: + body = await response.aread() + outcome = ( + response.status_code, + response.reason_phrase, + _headers(response), + body, + ) + finally: + await response.aclose() + except TimeoutError: + failure = APITimeoutError(call.timeout) + except Exception as error: + failure = _translate(error, call, self.config) + if failure is None: + raise + if failure is not None: + raise failure + assert outcome is not None + return _check_redirect(outcome) + + async def close(self) -> None: + self._closed = True + if self._owns_client: + await self._client.aclose() + + +def _check_redirect(outcome: _Outcome) -> _Outcome: + if 300 <= outcome[0] < 400: + raise RedirectError(outcome[0]) + return outcome diff --git a/src/lettermint/exceptions.py b/src/lettermint/exceptions.py index 757d672..dce8808 100644 --- a/src/lettermint/exceptions.py +++ b/src/lettermint/exceptions.py @@ -1,88 +1,232 @@ -"""Custom exceptions for the Lettermint SDK.""" +"""Exceptions of the Lettermint SDK. + +Every exception the SDK raises is a :class:`LettermintError`. No exception +carries request headers, API tokens or the underlying HTTP library's request +object, and none is chained to an ``httpx`` exception. +""" from __future__ import annotations -from typing import Any +from typing import Any, Literal, TypeAlias + +__all__ = [ + "APIConnectionError", + "APIError", + "APITimeoutError", + "AuthenticationError", + "ConflictError", + "LettermintConfigError", + "LettermintError", + "LettermintValidationError", + "NotFoundError", + "PermissionDeniedError", + "RateLimitError", + "RedirectError", + "ServerError", + "UnexpectedResponseError", + "ValidationError", + "WebhookVerificationError", + "WebhookVerificationReason", +] + + +def _rebuild( + cls: type[LettermintError], args: tuple[Any, ...], state: dict[str, Any] +) -> LettermintError: + error = cls.__new__(cls) + error.args = args + error.__dict__.update(state) + return error class LettermintError(Exception): - """Base exception for all Lettermint SDK errors.""" + """Base class of every exception the SDK raises.""" + + def __reduce__(self) -> tuple[Any, ...]: + # Exceptions travel between processes (multiprocessing, Celery). The + # keyword-only constructors below would break the default pickling. + return (_rebuild, (type(self), self.args, dict(self.__dict__))) + + +class LettermintConfigError(LettermintError): + """The client was configured or called incorrectly. + + For example a missing or unrecognised token, a token that the called + method cannot use, an invalid option or an invalid path parameter. Raised + before any request is made. + """ + + +class LettermintValidationError(LettermintError): + """The SDK rejected a request before sending it, for example invalid tags. + + Unlike :class:`ValidationError`, the API never saw this request. + """ - pass + def __init__(self, message: str, *, field: str | None = None) -> None: + super().__init__(message) + #: The offending field, for example ``tags`` or ``messages[2].tags``. + self.field = field -class HttpRequestError(LettermintError): - """Exception raised for HTTP request errors. +class APIError(LettermintError): + """The API answered with an error status (4xx or 5xx) and a JSON or empty body. - Attributes: - status_code: The HTTP status code of the response. - response_body: The parsed response body, if available. + Subclasses cover the common statuses; any other 4xx is a plain ``APIError``. """ def __init__( self, message: str, - status_code: int, - response_body: Any | None = None, + *, + status: int, + code: str | None = None, + details: Any = None, + body: Any = None, ) -> None: super().__init__(message) - self.status_code = status_code - self.response_body = response_body + #: The API's error message, or the HTTP reason phrase. + self.message = message + #: The HTTP status code. + self.status = status + #: Machine-readable error code from ``{"error": {"code"}}`` or a string ``error``. + self.code = code + #: Additional context from ``{"error": {"details"}}``, if the API sent any. + self.details = details + #: The decoded JSON error body, or ``None`` for an empty body. + self.body = body + + def __repr__(self) -> str: + return f"{type(self).__name__}(status={self.status!r}, code={self.code!r}, message={self.message!r})" + + +class AuthenticationError(APIError): + """HTTP 401: the token is missing, invalid or revoked.""" + + +class PermissionDeniedError(APIError): + """HTTP 403: the token may not perform this action, or the plan lacks the feature.""" + + +class NotFoundError(APIError): + """HTTP 404: the resource does not exist or is not visible to the token.""" -class ValidationError(HttpRequestError): - """Exception raised for validation errors (HTTP 422). +class ConflictError(APIError): + """HTTP 409: the request conflicts with the current state. - Attributes: - error_type: The type of validation error from the API. + For example an ``Idempotency-Key`` reused with a different body. """ + +class ValidationError(APIError): + """HTTP 422: the API rejected the request data.""" + def __init__( self, message: str, - error_type: str, - response_body: Any | None = None, + *, + status: int, + code: str | None = None, + details: Any = None, + body: Any = None, + errors: dict[str, list[str]] | None = None, ) -> None: - super().__init__(message, 422, response_body) - self.error_type = error_type + super().__init__(message, status=status, code=code, details=details, body=body) + #: Field errors from the ``{"message", "errors"}`` body, when the API sent them. + self.errors = errors -class ClientError(HttpRequestError): - """Exception raised for client errors (HTTP 400).""" +class RateLimitError(APIError): + """HTTP 429: too many requests.""" def __init__( self, message: str, - response_body: Any | None = None, + *, + status: int, + code: str | None = None, + details: Any = None, + body: Any = None, + retry_after: int | None = None, ) -> None: - super().__init__(message, 400, response_body) + super().__init__(message, status=status, code=code, details=details, body=body) + #: Seconds to wait, from the ``Retry-After`` header (seconds or an HTTP date). + self.retry_after = retry_after -class TimeoutError(LettermintError): - """Exception raised when a request times out.""" +class ServerError(APIError): + """HTTP 5xx with a JSON or empty body.""" - pass +class APITimeoutError(LettermintError): + """The request did not complete within the timeout. -class WebhookVerificationError(LettermintError): - """Base exception for webhook verification errors.""" + The timeout covers the whole request: connecting, sending, the response + headers and the body. The API may still have processed the request. + """ - pass + def __init__(self, timeout: float) -> None: + super().__init__(f"The request to the Lettermint API timed out after {timeout:g} seconds.") + #: The timeout in seconds. + self.timeout = timeout -class InvalidSignatureError(WebhookVerificationError): - """Exception raised when the webhook signature is invalid.""" +class APIConnectionError(LettermintError): + """The request could not be sent or the connection failed (DNS, TLS, refused, reset). - pass + The message names the underlying error. The exception is not chained to + it, because HTTP library exceptions hold the request and its headers. + """ -class TimestampToleranceError(WebhookVerificationError): - """Exception raised when the webhook timestamp is outside the tolerance window.""" +class UnexpectedResponseError(LettermintError): + """The response could not be decoded. - pass + An empty or non-JSON body where JSON was expected, or an error status + with a non-JSON body such as a proxy's HTML error page. + """ + + def __init__(self, message: str, *, status: int, body: str) -> None: + super().__init__(message) + #: The HTTP status code. + self.status = status + #: The first 200 characters of the response body. + self.body_excerpt = body if len(body) <= 200 else body[:200] + "…" -class JsonDecodeError(WebhookVerificationError): - """Exception raised when the webhook payload cannot be decoded as JSON.""" +class RedirectError(LettermintError): + """The API answered with a redirect (3xx). - pass + Redirects are never followed, so tokens are never sent to another location. + """ + + def __init__(self, status: int) -> None: + super().__init__( + f"The Lettermint API answered with a redirect (HTTP {status}). " + "Redirects are not followed; check the base_url option." + ) + #: The HTTP status code. + self.status = status + + +#: Why a webhook delivery failed verification. +WebhookVerificationReason: TypeAlias = Literal[ + "signature_header_missing", + "signature_header_malformed", + "delivery_header_missing", + "delivery_timestamp_mismatch", + "timestamp_out_of_tolerance", + "signature_mismatch", + "body_invalid", + "payload_invalid", +] + + +class WebhookVerificationError(LettermintError): + """A webhook delivery could not be verified. Reject the request; do not process its payload.""" + + def __init__(self, reason: WebhookVerificationReason, message: str) -> None: + super().__init__(message) + #: A machine-readable reason. + self.reason: WebhookVerificationReason = reason diff --git a/src/lettermint/types.py b/src/lettermint/types.py new file mode 100644 index 0000000..450cccd --- /dev/null +++ b/src/lettermint/types.py @@ -0,0 +1,10 @@ +"""Request and response types of the Lettermint API. + +Generated from the API specification (``lettermint._generated``). Responses are +plain ``dict``\\ s typed as ``TypedDict``\\ s; enums are open (``Literal[...] | str``). + + from lettermint.types import DomainData, ListDomainsQuery, MessageStatus +""" + +from ._generated.types import * # noqa: F403 +from ._generated.types import __all__ as __all__ diff --git a/src/lettermint/webhook.py b/src/lettermint/webhook.py index 5a3588d..8df04e5 100644 --- a/src/lettermint/webhook.py +++ b/src/lettermint/webhook.py @@ -1,238 +1,238 @@ -"""Webhook signature verification for the Lettermint SDK.""" +"""Verification of Lettermint webhook deliveries.""" from __future__ import annotations import hashlib import hmac import json +import re import time -from typing import Any - -from .exceptions import ( - InvalidSignatureError, - JsonDecodeError, - TimestampToleranceError, - WebhookVerificationError, -) - -SIGNATURE_HEADER = "X-Lettermint-Signature" -DELIVERY_HEADER = "X-Lettermint-Delivery" -DEFAULT_TOLERANCE = 300 # 5 minutes +from collections.abc import Iterable, Mapping +from typing import Any, NoReturn, TypeAlias + +from typing_extensions import Buffer, NotRequired, Required, TypedDict + +from ._core import Secret +from ._emails import as_bytes +from ._generated.types import WebhookEvent +from .exceptions import LettermintConfigError, WebhookVerificationError, WebhookVerificationReason + +__all__ = ["Webhook", "WebhookHeaders", "WebhookPayload"] + +SIGNATURE_HEADER = "x-lettermint-signature" +DELIVERY_HEADER = "x-lettermint-delivery" +DEFAULT_TOLERANCE = 300 +_PRINTABLE_ASCII = re.compile(r"[\x20-\x7e]*") +_DIGITS = re.compile(r"[0-9]+") +_HEX_SHA256 = re.compile(r"[0-9a-fA-F]{64}") +_MAX_SAFE_INTEGER = 2**53 - 1 + +#: Request headers: any mapping (``dict``, Django ``request.headers``, Flask, +#: Starlette, ``http.client`` messages, ``httpx.Headers``) or an iterable of +#: ``(name, value)`` pairs, such as the raw ASGI headers. Names are +#: case-insensitive; values may be ``str``, ``bytes`` or a list of them. +WebhookHeaders: TypeAlias = Mapping[Any, Any] | Iterable[tuple[Any, Any]] + + +class WebhookPayload(TypedDict): + """A verified webhook delivery. The API may send more keys; they are kept.""" + + #: The delivery id. + id: NotRequired[str] + #: The event name, for example ``message.delivered``. Unknown events pass through as strings. + event: Required[WebhookEvent] + #: When the event occurred, ISO 8601. + timestamp: NotRequired[str] + data: Required[dict[str, Any]] + + +def _fail(reason: WebhookVerificationReason, message: str) -> NoReturn: + raise WebhookVerificationError(reason, message) + + +def _text(value: object) -> str | None: + if isinstance(value, str): + return value + if isinstance(value, (bytes, bytearray)): + return bytes(value).decode("latin-1") + return None + + +def _read_header(headers: WebhookHeaders, name: str) -> list[str | None]: + """Every value of header ``name`` (case-insensitive); ``None`` for a value of another type.""" + if isinstance(headers, (str, bytes, bytearray)): + raise TypeError( + "Webhook.verify() takes the request headers (a mapping or (name, value) pairs); " + "use verify_signature() for a signature header value" + ) + pairs: Iterable[Any] + if isinstance(headers, Mapping) or hasattr(headers, "items"): + multi = getattr(headers, "multi_items", None) # httpx.Headers joins duplicates in items() + pairs = multi() if callable(multi) else headers.items() + else: + pairs = headers + values: list[str | None] = [] + for pair in pairs: + try: + key, value = pair + except (TypeError, ValueError): + continue + key_text = _text(key) + if key_text is None or key_text.lower() != name: + continue + for item in value if isinstance(value, (list, tuple)) else [value]: + if item is not None: + values.append(_text(item)) + return values + + +def _parse_signature(header: str) -> tuple[str, list[bytes]]: + def malformed(detail: str) -> NoReturn: + _fail("signature_header_malformed", f"The signature header is malformed: {detail}.") + + if not _PRINTABLE_ASCII.fullmatch(header): + malformed("it contains non-ASCII or control characters") + timestamp: str | None = None + signatures: list[bytes] = [] + for part in header.split(","): + entry = part.strip() + key, separator, value = entry.partition("=") + if not separator: + continue + if key == "t": + if timestamp is not None: + malformed("it has more than one timestamp") + if not _DIGITS.fullmatch(value) or len(value) > 16 or int(value) > _MAX_SAFE_INTEGER: + malformed("the timestamp is not a number of seconds") + timestamp = value + elif key == "v1" and _HEX_SHA256.fullmatch(value): + signatures.append(bytes.fromhex(value)) + if timestamp is None: + malformed("the timestamp (t=) is missing") + if not signatures: + malformed("no v1 signature is present") + return timestamp, signatures + + +def _body_bytes(body: object) -> bytes: + binary = None if isinstance(body, str) else as_bytes(body) + if isinstance(body, str): + data = body.encode("utf-8") + elif binary is not None: + data = binary + else: + _fail("body_invalid", "Pass the raw request body (str or bytes), not parsed JSON.") + if not data: + _fail("body_invalid", "The raw request body is empty.") + return data class Webhook: - """Webhook signature verifier for Lettermint webhooks. + """Verifies Lettermint webhook deliveries. - Verifies webhook signatures using HMAC-SHA256 and validates timestamps - to prevent replay attacks. + The signature is HMAC-SHA256 over ``"." + raw body`` with the + endpoint's signing secret (``whsec_...``, used as is), compared in + constant time. Any ``v1`` signature in the header may match. - Args: - secret: The webhook signing secret. - tolerance: Maximum allowed time difference in seconds. Defaults to 300 (5 minutes). + :: - Raises: - ValueError: If secret is empty. - - Example: - >>> from lettermint import Webhook - >>> - >>> webhook = Webhook(secret="your-webhook-secret") - >>> payload = webhook.verify_headers(request.headers, request.body) - >>> print(payload["event"]) + webhook = Webhook(os.environ["LETTERMINT_WEBHOOK_SECRET"]) + event = webhook.verify(request.body, request.headers) """ - def __init__(self, secret: str, tolerance: int = DEFAULT_TOLERANCE) -> None: - if not secret: - raise ValueError("Webhook secret cannot be empty") - self._secret = secret - self._tolerance = tolerance - - def verify( - self, - payload: str | bytes, - signature: str, - timestamp: int | None = None, - ) -> dict[str, Any]: - """Verify a webhook signature and return the decoded payload. + __slots__ = ("_secret", "_tolerance") + def __init__(self, secret: str, tolerance: int = DEFAULT_TOLERANCE) -> None: + """ Args: - payload: The raw request body as a string or bytes. Bytes are signed as-is. - signature: The signature header value (format: t={timestamp},v1={hash}). - timestamp: Optional timestamp from delivery header for cross-validation. - - Returns: - The decoded webhook payload as a dictionary. - - Raises: - WebhookVerificationError: If signature format is invalid or timestamps mismatch. - InvalidSignatureError: If signature doesn't match. - TimestampToleranceError: If timestamp is outside tolerance window. - JsonDecodeError: If payload is not valid JSON. - - Example: - >>> payload = webhook.verify( - ... payload=request_body, - ... signature=request.headers["X-Lettermint-Signature"], - ... ) + secret: The webhook's signing secret, including the ``whsec_`` prefix. + tolerance: Maximum difference between the signed timestamp and the + current time, in seconds, in either direction. ``0`` accepts + only the current second; it does not disable the check. """ - parsed = self._parse_signature(signature) - signature_timestamp = parsed["timestamp"] - expected_signature = parsed["signature"] - - if timestamp is not None and timestamp != signature_timestamp: - raise WebhookVerificationError( - "Timestamp mismatch between signature and delivery headers" - ) - - self._validate_timestamp(signature_timestamp) - - raw_payload = payload if isinstance(payload, bytes) else payload.encode() - signed_content = f"{signature_timestamp}.".encode() + raw_payload - computed_signature = hmac.new( - self._secret.encode(), - signed_content, - hashlib.sha256, - ).hexdigest() - - # Compare as bytes: compare_digest raises TypeError for non-ASCII str input. - if not hmac.compare_digest( - computed_signature.encode(), expected_signature.encode("utf-8", "replace") - ): - raise InvalidSignatureError("Signature verification failed") - - try: - data: dict[str, Any] = json.loads(payload) - except ValueError as e: - raise JsonDecodeError(f"Failed to decode webhook payload: {e}") from e - - return data + if not isinstance(secret, str) or not secret: + raise LettermintConfigError("The webhook signing secret must be a non-empty string.") + if isinstance(tolerance, bool) or not isinstance(tolerance, int) or tolerance < 0: + raise LettermintConfigError("tolerance must be a non-negative whole number of seconds.") + self._secret = Secret(secret) + self._tolerance = tolerance - def verify_headers( - self, - headers: dict[str, str], - payload: str | bytes, - ) -> dict[str, Any]: - """Verify a webhook using HTTP headers and return the decoded payload. + @property + def tolerance(self) -> int: + """The timestamp tolerance in seconds.""" + return self._tolerance - Args: - headers: HTTP headers from the request (case-insensitive). - payload: The raw request body as a string or bytes. Bytes are signed as-is. + def verify(self, raw_body: str | Buffer, headers: WebhookHeaders) -> WebhookPayload: + """Verifies a delivery from its raw body and request headers; returns the payload. - Returns: - The decoded webhook payload as a dictionary. + Requires ``X-Lettermint-Signature`` and ``X-Lettermint-Delivery``, which + must equal the signed timestamp. Raises: - WebhookVerificationError: If required headers are missing or verification fails. - InvalidSignatureError: If signature doesn't match. - TimestampToleranceError: If timestamp is outside tolerance window. - JsonDecodeError: If payload is not valid JSON. - - Example: - >>> payload = webhook.verify_headers( - ... headers=dict(request.headers), - ... payload=request.body, - ... ) + WebhookVerificationError: The delivery is not genuine. Its ``reason`` + says why. """ - normalized_headers = self._normalize_headers(headers) - - signature = normalized_headers.get(SIGNATURE_HEADER.lower()) - timestamp_str = normalized_headers.get(DELIVERY_HEADER.lower()) - - if signature is None: - raise WebhookVerificationError(f"Missing signature header: {SIGNATURE_HEADER}") - - if timestamp_str is None: - raise WebhookVerificationError(f"Missing delivery header: {DELIVERY_HEADER}") - - try: - timestamp = int(timestamp_str) - except ValueError: - raise WebhookVerificationError( - f"Invalid timestamp format in {DELIVERY_HEADER} header" - ) from None - - return self.verify(payload, signature, timestamp) + signatures = _read_header(headers, SIGNATURE_HEADER) + if not signatures: + _fail("signature_header_missing", "The X-Lettermint-Signature header is missing.") + if len(signatures) > 1 or signatures[0] is None: + _fail( + "signature_header_malformed", + "The request has more than one X-Lettermint-Signature header.", + ) + deliveries = _read_header(headers, DELIVERY_HEADER) + if not deliveries: + _fail("delivery_header_missing", "The X-Lettermint-Delivery header is missing.") + if len(deliveries) > 1 or deliveries[0] is None: + _fail( + "delivery_timestamp_mismatch", + "The request has more than one X-Lettermint-Delivery header.", + ) + return self.verify_signature(raw_body, signatures[0], deliveries[0]) - @staticmethod def verify_signature( - payload: str | bytes, - signature: str, - secret: str, - timestamp: int | None = None, - tolerance: int = DEFAULT_TOLERANCE, - ) -> dict[str, Any]: - """Static convenience method to verify a webhook signature. - - Args: - payload: The raw request body as a string or bytes. Bytes are signed as-is. - signature: The signature header value (format: t={timestamp},v1={hash}). - secret: The webhook signing secret. - timestamp: Optional timestamp from delivery header for cross-validation. - tolerance: Maximum allowed time difference in seconds. Defaults to 300. + self, raw_body: str | Buffer, signature_header: str, timestamp: str | int | None = None + ) -> WebhookPayload: + """Verifies the raw body against an ``X-Lettermint-Signature`` value. - Returns: - The decoded webhook payload as a dictionary. + For setups where the headers are not at hand. When ``timestamp`` (the + ``X-Lettermint-Delivery`` value) is given, it must equal the signed + timestamp. Raises: - ValueError: If secret is empty. - WebhookVerificationError: If signature format is invalid or timestamps mismatch. - InvalidSignatureError: If signature doesn't match. - TimestampToleranceError: If timestamp is outside tolerance window. - JsonDecodeError: If payload is not valid JSON. - - Example: - >>> payload = Webhook.verify_signature( - ... payload=request_body, - ... signature=request.headers["X-Lettermint-Signature"], - ... secret="your-webhook-secret", - ... ) + WebhookVerificationError: The delivery is not genuine. """ - webhook = Webhook(secret, tolerance) - return webhook.verify(payload, signature, timestamp) - - def _parse_signature(self, signature: str) -> dict[str, Any]: - """Parse the signature header into timestamp and signature hash.""" - parts = signature.split(",") - - parsed_timestamp: int | None = None - parsed_signature: str | None = None - - for part in parts: - key_value = part.split("=", 1) - if len(key_value) != 2: - continue - - key, value = key_value - - if key == "t": - try: - parsed_timestamp = int(value) - except ValueError: - continue - elif key == "v1": - parsed_signature = value - - if parsed_timestamp is None or parsed_signature is None: - raise WebhookVerificationError( - "Invalid signature format. Expected format: t={timestamp},v1={signature}" + if not isinstance(signature_header, str) or not signature_header.strip(): + _fail("signature_header_missing", "The X-Lettermint-Signature header is missing.") + signed_at, candidates = _parse_signature(signature_header) + if timestamp is not None and str(timestamp).strip() != signed_at: + _fail( + "delivery_timestamp_mismatch", + "The X-Lettermint-Delivery header does not match the signed timestamp.", ) - - return { - "timestamp": parsed_timestamp, - "signature": parsed_signature, - } - - def _validate_timestamp(self, timestamp: int) -> None: - """Validate that the timestamp is within the tolerance window.""" - current_time = int(time.time()) - difference = abs(current_time - timestamp) - - if difference > self._tolerance: - raise TimestampToleranceError( - f"Timestamp outside tolerance window. " - f"Difference: {difference} seconds, Tolerance: {self._tolerance} seconds" + body = _body_bytes(raw_body) + if abs(int(time.time()) - int(signed_at)) > self._tolerance: + _fail( + "timestamp_out_of_tolerance", + "The signed timestamp is outside the allowed tolerance.", ) + key = self._secret.reveal().encode("utf-8") + expected = hmac.new(key, signed_at.encode("ascii") + b"." + body, hashlib.sha256).digest() + matched = False + for candidate in candidates: + matched |= hmac.compare_digest(candidate, expected) + if not matched: + _fail("signature_mismatch", "The webhook signature does not match.") + try: + payload = json.loads(body.decode("utf-8")) + except ValueError: + _fail("payload_invalid", "The webhook payload is not valid JSON.") + if not isinstance(payload, dict): + _fail("payload_invalid", "The webhook payload is not a JSON object.") + return payload # type: ignore[return-value] + + def __repr__(self) -> str: + return f"Webhook(tolerance={self._tolerance})" - def _normalize_headers(self, headers: dict[str, str]) -> dict[str, str]: - """Normalize headers to lowercase keys.""" - return {key.lower(): value for key, value in headers.items()} + def __reduce__(self) -> NoReturn: + raise TypeError("Webhook verifiers cannot be pickled; they hold the signing secret") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3573a99 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,98 @@ +"""Shared fixtures: fake tokens and a recording mock API on ``httpx.MockTransport``. + +No test talks to the real Lettermint API. +""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Callable +from typing import Any + +import httpx +import pytest + +import lettermint + +SENDING_TOKEN = "lm_TestSendingToken0123456789abcdef" +TEAM_TOKEN = "lm_team_TestTeamToken0123456789abcdef" +BASE_URL = "https://api.lettermint.test/v1" + +Handler = Callable[[httpx.Request], Any] + + +class MockAPI: + """Records every request and answers with ``handler`` (default: a JSON ``{}``).""" + + def __init__(self) -> None: + self.requests: list[httpx.Request] = [] + self.handler: Handler = lambda request: httpx.Response(200, json={}) + + def respond(self, handler: Handler) -> None: + self.handler = handler + + def json(self, status: int, body: Any, **headers: str) -> None: + self.handler = lambda request: httpx.Response(status, json=body, headers=headers) + + def _record(self, request: httpx.Request) -> Any: + request.read() + self.requests.append(request) + return self.handler(request) + + def sync_client(self, **options: Any) -> httpx.Client: + return httpx.Client(transport=httpx.MockTransport(self._record), **options) + + def async_client(self, **options: Any) -> httpx.AsyncClient: + async def handle(request: httpx.Request) -> httpx.Response: + result = self._record(request) + if inspect.isawaitable(result): + result = await result + return result # type: ignore[no-any-return] + + return httpx.AsyncClient(transport=httpx.MockTransport(handle), **options) + + @property + def last(self) -> httpx.Request: + return self.requests[-1] + + def body(self, index: int = -1) -> Any: + return json.loads(self.requests[index].content) + + +@pytest.fixture +def api() -> MockAPI: + return MockAPI() + + +@pytest.fixture +def client(api: MockAPI) -> lettermint.Lettermint: + return lettermint.Lettermint( + sending_token=SENDING_TOKEN, + team_token=TEAM_TOKEN, + base_url=BASE_URL, + http_client=api.sync_client(), + ) + + +@pytest.fixture +def aclient(api: MockAPI) -> lettermint.AsyncLettermint: + return lettermint.AsyncLettermint( + sending_token=SENDING_TOKEN, + team_token=TEAM_TOKEN, + base_url=BASE_URL, + http_client=api.async_client(), + ) + + +@pytest.fixture(params=["asyncio", "trio"]) +def anyio_backend(request: pytest.FixtureRequest) -> str: + return str(request.param) + + +MESSAGE: lettermint.EmailMessage = { + "from": "Acme ", + "to": ["jane@example.test"], + "subject": "Welcome", + "html": "

Hi

", +} diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..369e314 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,221 @@ +"""Client construction, tokens, options, redaction and lifecycle.""" + +from __future__ import annotations + +import copy +import pickle +import pprint +from typing import Any + +import httpx +import pytest + +import lettermint +from lettermint import AsyncLettermint, Lettermint, LettermintConfigError + +from .conftest import BASE_URL, SENDING_TOKEN, TEAM_TOKEN, MockAPI + +CLIENTS = [Lettermint, AsyncLettermint] + + +@pytest.mark.parametrize("cls", CLIENTS) +@pytest.mark.parametrize( + ("token", "kind"), + [ + ("lm_team_Conformance0Token1Fake2Value3Only4Test5D", "team"), + ("lm_Proj32Conformance0Token1Fake2Val", "sending"), + ("lm_Proj22Conformance0Toke", "sending"), + ], +) +def test_token_string_is_classified_by_prefix(cls: Any, token: str, kind: str) -> None: + client = cls(token) + config = client._transport.config + assert (config.team_token is not None) == (kind == "team") + assert (config.sending_token is not None) == (kind == "sending") + + +@pytest.mark.parametrize("cls", CLIENTS) +@pytest.mark.parametrize( + "token", + [ + "lm_sso_SsoConformance0Token1Fake2Value3", + "", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjb25mb3JtYW5jZSJ9.Y29uZm9ybWFuY2Utc2lnbmF0dXJl", + "sk_conformance_0123456789abcdef", + "lm_team_", + "lm_", + "lm_with space", + " lm_leadingspace", + ], +) +def test_other_token_formats_are_config_errors_without_the_token(cls: Any, token: str) -> None: + with pytest.raises(LettermintConfigError) as caught: + cls(token) + assert ( + str(caught.value) + == "Unrecognised token format; pass sending_token=... or team_token=... instead." + ) + if token: + assert token not in str(caught.value) + + +@pytest.mark.parametrize("cls", CLIENTS) +def test_a_token_is_required(cls: Any) -> None: + with pytest.raises(LettermintConfigError, match="Pass sending_token, team_token or both"): + cls() + + +@pytest.mark.parametrize("cls", CLIENTS) +def test_shorthand_and_explicit_tokens_are_exclusive(cls: Any) -> None: + with pytest.raises(LettermintConfigError, match="not both"): + cls(SENDING_TOKEN, team_token=TEAM_TOKEN) + + +@pytest.mark.parametrize("cls", CLIENTS) +@pytest.mark.parametrize( + ("options", "message"), + [ + ({"sending_token": ""}, "sending_token must be a non-empty string"), + ({"team_token": 42}, "team_token must be a non-empty string"), + ({"sending_token": "lm_abc\n"}, "not allowed in an HTTP header"), + ({"sending_token": SENDING_TOKEN, "timeout": 0}, "timeout must be a positive number"), + ( + {"sending_token": SENDING_TOKEN, "timeout": float("inf")}, + "timeout must be a positive number", + ), + ({"sending_token": SENDING_TOKEN, "timeout": True}, "timeout must be a positive number"), + ({"sending_token": SENDING_TOKEN, "base_url": "ftp://x"}, "absolute http"), + ({"sending_token": SENDING_TOKEN, "base_url": "/v1"}, "absolute http"), + ({"sending_token": SENDING_TOKEN, "base_url": "https://u:p@api.test"}, "credentials"), + ({"sending_token": SENDING_TOKEN, "base_url": "https://api.test/v1?x=1"}, "query"), + ({"sending_token": SENDING_TOKEN, "http_client": object()}, "http_client must be"), + ], +) +def test_invalid_options(cls: Any, options: dict[str, Any], message: str) -> None: + with pytest.raises(LettermintConfigError, match=message) as caught: + cls(**options) + assert "lm_abc" not in str(caught.value) + + +def test_the_http_client_type_must_match() -> None: + with pytest.raises(LettermintConfigError, match="httpx.Client"): + Lettermint(sending_token=SENDING_TOKEN, http_client=httpx.AsyncClient()) # type: ignore[arg-type] + with pytest.raises(LettermintConfigError, match="httpx.AsyncClient"): + AsyncLettermint(sending_token=SENDING_TOKEN, http_client=httpx.Client()) # type: ignore[arg-type] + + +def test_base_url_trailing_slashes_are_removed(api: MockAPI) -> None: + client = Lettermint(TEAM_TOKEN, base_url=BASE_URL + "//", http_client=api.sync_client()) + api.respond(lambda request: httpx.Response(200, text="pong")) + client.ping() + assert str(api.last.url) == BASE_URL + "/ping" + + +def _renderings(value: Any) -> list[str]: + rendered = [repr(value), str(value), f"{value}", f"{value!r}", pprint.pformat(value)] + if hasattr(value, "__dict__"): + rendered.append(repr(vars(value))) + rendered += [repr(item) for item in vars(value).values()] + rendered += [repr(vars(item)) for item in vars(value).values() if hasattr(item, "__dict__")] + return rendered + + +@pytest.mark.parametrize("cls", CLIENTS) +def test_tokens_never_appear_in_debug_output(cls: Any) -> None: + client = cls(sending_token=SENDING_TOKEN, team_token=TEAM_TOKEN) + subjects = [ + client, + client.emails, + client.domains, + client.webhooks.deliveries, + client.projects.report_forwarding, + client._transport, + client._transport.config, + client.emails.compose().from_("a@acme.test").to("b@example.test"), + ] + for subject in subjects: + for text in _renderings(subject): + assert SENDING_TOKEN not in text + assert TEAM_TOKEN not in text + assert repr(client) == ( + f"{cls.__name__}(base_url='https://api.lettermint.co/v1', timeout=30.0, " + "sending_token=[redacted], team_token=[redacted])" + ) + assert repr(cls(TEAM_TOKEN)).endswith("sending_token=None, team_token=[redacted])") + + +@pytest.mark.parametrize("cls", CLIENTS) +def test_clients_and_credentials_cannot_be_pickled_or_copied(cls: Any) -> None: + client = cls(sending_token=SENDING_TOKEN) + for subject in ( + client, + client.emails, + client._transport, + client._transport.config.sending_token, + ): + with pytest.raises(TypeError): + pickle.dumps(subject) + with pytest.raises(TypeError): + copy.deepcopy(client._transport.config) + + +def test_sync_context_manager_closes_the_client_it_created() -> None: + with Lettermint(SENDING_TOKEN) as client: + http = client._transport._client + assert not http.is_closed + assert http.is_closed + with pytest.raises(LettermintConfigError, match="emails.ping: the client is closed"): + client.emails.ping() + + +@pytest.mark.anyio +async def test_async_context_manager_closes_the_client_it_created() -> None: + async with AsyncLettermint(SENDING_TOKEN) as client: + http = client._transport._client + assert not http.is_closed + assert http.is_closed + with pytest.raises(LettermintConfigError, match="the client is closed"): + await client.emails.ping() + + +def test_an_injected_http_client_is_not_closed(api: MockAPI) -> None: + http = api.sync_client() + with Lettermint(SENDING_TOKEN, http_client=http): + pass + assert not http.is_closed + + +@pytest.mark.anyio +async def test_an_injected_async_http_client_is_not_closed(api: MockAPI) -> None: + http = api.async_client() + async with AsyncLettermint(SENDING_TOKEN, http_client=http): + pass + assert not http.is_closed + await http.aclose() + + +def test_a_closed_injected_http_client_is_a_config_error(api: MockAPI) -> None: + http = api.sync_client() + client = Lettermint(SENDING_TOKEN, http_client=http) + http.close() + with pytest.raises(LettermintConfigError, match="the HTTP client is closed") as caught: + client.emails.ping() + assert caught.value.__context__ is None and caught.value.__cause__ is None + + +def test_the_default_http_client_does_not_follow_redirects() -> None: + client = Lettermint(SENDING_TOKEN, timeout=12.5) + http = client._transport._client + assert isinstance(http, httpx.Client) + assert http.follow_redirects is False + assert http.timeout.read == 12.5 + client.close() + + +def test_version_and_user_agent(api: MockAPI, client: Lettermint) -> None: + api.respond(lambda request: httpx.Response(200, text="pong")) + client.ping() + assert api.last.headers["user-agent"].startswith( + f"lettermint-python/{lettermint.__version__} python/" + ) + assert api.last.headers["accept"] == "application/json" diff --git a/tests/test_emails.py b/tests/test_emails.py new file mode 100644 index 0000000..4d9c14b --- /dev/null +++ b/tests/test_emails.py @@ -0,0 +1,332 @@ +"""Sending: stateless sends, the immutable builder, tags, attachments and batches.""" + +from __future__ import annotations + +import base64 +import threading +from datetime import datetime, timedelta, timezone +from typing import Any + +import anyio +import httpx +import pytest + +from lettermint import ( + AsyncEmailBuilder, + AsyncLettermint, + EmailBuilder, + EmailMessage, + Lettermint, + LettermintValidationError, +) + +from .conftest import MESSAGE, MockAPI + +PENDING = {"message_id": "m1", "status": "pending"} + + +@pytest.fixture(autouse=True) +def accept_sends(api: MockAPI) -> None: + api.json(202, PENDING) + + +def test_send_posts_the_message_as_given(api: MockAPI, client: Lettermint) -> None: + message: EmailMessage = { + "from": "Acme ", + "to": ["jane@example.test"], + "reply_to": ["support@acme.test"], + "subject": "Your order", + "html": "

Shipped

", + "metadata": {"order_id": "1234"}, + "tag": "orders", + "tags": [{"name": "campaign", "value": "orders"}], + "settings": {"track_opens": False}, + "sandbox_result": "hard_bounced", + } + assert client.emails.send(message) == PENDING + assert api.body() == message + + +def test_send_does_not_change_the_message(api: MockAPI, client: Lettermint) -> None: + message: EmailMessage = { + **MESSAGE, + "attachments": [{"filename": "a.bin", "content": b"\x00\xff"}], + } + client.emails.send(message) + assert message["attachments"][0]["content"] == b"\x00\xff" + assert api.body()["attachments"] == [{"filename": "a.bin", "content": "AP8="}] + + +def test_the_builder_is_immutable(api: MockAPI, client: Lettermint) -> None: + base = client.emails.compose().from_("Acme ").subject("Welcome") + jane = base.to("jane@example.test").html("

Hi Jane

") + john = base.to("john@example.test").cc("boss@example.test").html("

Hi John

") + assert base.build() == {"from": "Acme ", "to": [], "subject": "Welcome"} + jane.send() + john.send(idempotency_key="welcome-john") + jane.send() + assert [api.body(i)["to"] for i in range(3)] == [ + ["jane@example.test"], + ["john@example.test"], + ["jane@example.test"], + ] + assert "cc" not in api.body(0) and "cc" not in api.body(2) + assert "idempotency-key" not in api.requests[0].headers + assert api.requests[1].headers["idempotency-key"] == "welcome-john" + assert "idempotency-key" not in api.requests[2].headers + with pytest.raises(AttributeError, match="immutable"): + base._message = {} + + +def test_every_setter(api: MockAPI, client: Lettermint) -> None: + when = datetime(2026, 10, 20, 9, 0, tzinfo=timezone(timedelta(hours=2))) + builder = ( + client.emails.compose() + .from_("hello@acme.test") + .to("a@example.test", "b@example.test") + .cc("c@example.test") + .bcc("d@example.test") + .reply_to("e@example.test") + .subject("All fields") + .html("

x

") + .text("x") + .headers({"X-Custom": "1"}) + .metadata({"k": "v"}) + .tag("legacy") + .tags([{"name": "campaign", "value": "x"}]) + .route("broadcast") + .scheduled_at(when) + .settings({"track_opens": True, "track_clicks": False, "tls": "enforced"}) + .sandbox_result("delivered") + .attach("invoice.pdf", b"%PDF-1.7", content_type="application/pdf") + .attach("logo.png", "aGk=", content_id="logo") + ) + builder.send() + assert api.body() == { + "from": "hello@acme.test", + "to": ["a@example.test", "b@example.test"], + "subject": "All fields", + "cc": ["c@example.test"], + "bcc": ["d@example.test"], + "reply_to": ["e@example.test"], + "html": "

x

", + "text": "x", + "headers": {"X-Custom": "1"}, + "metadata": {"k": "v"}, + "tag": "legacy", + "tags": [{"name": "campaign", "value": "x"}], + "route": "broadcast", + "scheduled_at": "2026-10-20T09:00:00+02:00", + "settings": {"track_opens": True, "track_clicks": False, "tls": "enforced"}, + "sandbox_result": "delivered", + "attachments": [ + { + "filename": "invoice.pdf", + "content": base64.b64encode(b"%PDF-1.7").decode(), + "content_type": "application/pdf", + }, + {"filename": "logo.png", "content": "aGk=", "content_id": "logo"}, + ], + } + removed = builder.html(None).text(None).tag(None).scheduled_at(None).build() + assert not {"html", "text", "tag", "scheduled_at"} & set(removed) + assert builder.scheduled_at("tomorrow 9am").build()["scheduled_at"] == "tomorrow 9am" + + +def test_naive_datetimes_are_rejected(client: Lettermint) -> None: + with pytest.raises(LettermintValidationError, match="timezone-aware") as caught: + client.emails.compose().scheduled_at(datetime(2026, 10, 20, 9, 0)) + assert caught.value.field == "scheduled_at" + + +def test_compose_from_a_message_copies_it(client: Lettermint) -> None: + message: EmailMessage = {**MESSAGE, "to": ["jane@example.test"]} + builder = client.emails.compose(message) + message["to"].append("mallory@example.test") + assert builder.build()["to"] == ["jane@example.test"] + + +def test_build_returns_a_copy(client: Lettermint) -> None: + builder = client.emails.compose().to("jane@example.test") + built = builder.build() + built["to"].append("mallory@example.test") + assert builder.build()["to"] == ["jane@example.test"] + + +def test_repr_summarizes_attachments(client: Lettermint) -> None: + builder = client.emails.compose().attach("a.bin", b"\x00" * 1000).attach("b.txt", "aGk=") + text = repr(builder) + assert text.startswith("EmailBuilder({") + assert "<1000 bytes>" in text and "<4 base64 characters>" in text + + +@pytest.mark.parametrize( + ("tags", "legacy", "message"), + [ + ([{"name": "not valid!", "value": "x"}], None, r"names must match"), + ([{"name": "a" * 33, "value": "x"}], None, r"names must match"), + ([{"name": "ok", "value": "v" * 65}], None, r"values must match"), + ([{"name": "ok", "value": ""}], None, r"values must match"), + ([{"name": "__lettermint_x", "value": "x"}], None, r"must not start with __lettermint"), + ([{"name": "__LETTERMINT", "value": "x"}], None, r"must not start with __lettermint"), + ([{"name": "a", "value": "1"}, {"name": "a", "value": "2"}], None, r"must be unique"), + ( + [{"name": f"t{i}", "value": "x"} for i in range(21)], + None, + r"No more than 20 message tags", + ), + ( + [{"name": f"t{i}", "value": "x"} for i in range(20)], + "legacy", + r"A legacy tag and no more than 19", + ), + ([{"name": "a"}], None, r"mappings with string values"), + ("campaign", None, r"must be a list"), + ], +) +def test_tag_validation( + api: MockAPI, client: Lettermint, tags: Any, legacy: str | None, message: str +) -> None: + base = client.emails.compose().from_("a@acme.test").to("b@example.test").subject("s") + if legacy: + base = base.tag(legacy) + snapshot = base.build() + with pytest.raises(LettermintValidationError, match=message) as caught: + base.tags(tags) + assert caught.value.field == "tags" + assert base.build() == snapshot # a rejected setter leaves the builder unchanged + with pytest.raises(LettermintValidationError, match=message): + client.emails.send({**MESSAGE, "tags": tags, **({"tag": legacy} if legacy else {})}) # type: ignore[typeddict-item] + assert api.requests == [] + + +def test_twenty_tags_and_case_sensitive_names_are_fine(client: Lettermint) -> None: + tags = [{"name": f"t{i}", "value": "x"} for i in range(19)] + [{"name": "T0", "value": "y"}] + client.emails.compose().tags(tags) # type: ignore[arg-type] + + +def test_attachment_validation(api: MockAPI, client: Lettermint) -> None: + with pytest.raises(LettermintValidationError, match="needs a filename") as caught: + client.emails.send({**MESSAGE, "attachments": [{"filename": "", "content": "aGk="}]}) + assert caught.value.field == "attachments[0]" + with pytest.raises(LettermintValidationError, match="base64 string or bytes"): + client.emails.compose().attach("a.txt", 42) # type: ignore[arg-type] + with pytest.raises(LettermintValidationError, match="must be a mapping"): + client.emails.send(["not", "a", "message"]) # type: ignore[arg-type] + assert api.requests == [] + + +def test_send_batch_mixes_messages_and_builders(api: MockAPI, client: Lettermint) -> None: + api.json(202, [PENDING, PENDING]) + builder = ( + client.emails.compose() + .from_("a@acme.test") + .to("b@example.test") + .subject("B") + .attach("x.bin", b"\x01") + ) + result = client.emails.send_batch([MESSAGE, builder], idempotency_key="batch-1") + assert result == [PENDING, PENDING] + assert str(api.last.url).endswith("/send/batch") + assert api.last.headers["idempotency-key"] == "batch-1" + assert api.body() == [ + MESSAGE, + { + "from": "a@acme.test", + "to": ["b@example.test"], + "subject": "B", + "attachments": [{"filename": "x.bin", "content": "AQ=="}], + }, + ] + + +def test_send_batch_names_the_bad_message(api: MockAPI, client: Lettermint) -> None: + with pytest.raises(LettermintValidationError) as caught: + client.emails.send_batch( + [MESSAGE, {**MESSAGE, "tags": [{"name": "bad name", "value": "x"}]}] + ) + assert caught.value.field == "messages[1].tags" + with pytest.raises(LettermintValidationError, match="takes a list"): + client.emails.send_batch(MESSAGE) # type: ignore[arg-type] + assert api.requests == [] + + +def test_a_failed_send_leaves_nothing_behind(api: MockAPI, client: Lettermint) -> None: + api.json(500, {"message": "Server Error"}) + full = ( + client.emails.compose() + .from_("a@acme.test") + .to("x@example.test") + .cc("cc@example.test") + .subject("A") + ) + with pytest.raises(Exception, match="Server Error"): + full.send(idempotency_key="key-a") + api.json(202, PENDING) + client.emails.compose().from_("c@acme.test").to("c@example.test").subject("C").send() + assert api.body() == {"from": "c@acme.test", "to": ["c@example.test"], "subject": "C"} + assert "idempotency-key" not in api.last.headers + + +def test_threads_share_a_base_builder_safely(api: MockAPI, client: Lettermint) -> None: + base = client.emails.compose().from_("a@acme.test").subject("Hi") + barrier = threading.Barrier(8) + + def send(index: int) -> None: + draft = base.to(f"user{index}@example.test") + barrier.wait() + draft.html(f"

{index}

").send(idempotency_key=f"key-{index}") + + threads = [threading.Thread(target=send, args=(i,)) for i in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + seen = { + (api.body(i)["to"][0], api.body(i)["html"], request.headers["idempotency-key"]) + for i, request in enumerate(api.requests) + } + assert seen == {(f"user{i}@example.test", f"

{i}

", f"key-{i}") for i in range(8)} + + +@pytest.mark.anyio +async def test_async_tasks_interleaving_setters_stay_isolated( + api: MockAPI, aclient: AsyncLettermint +) -> None: + async def compose_and_send(name: str) -> None: + builder: AsyncEmailBuilder = aclient.emails.compose() + for setter, value in ( + ("from_", f"{name}@acme.test"), + ("to", f"{name}@example.test"), + ("subject", name), + ("html", f"

{name}

"), + ): + builder = getattr(builder, setter)(value) + await anyio.sleep(0) + await builder.send(idempotency_key=f"key-{name}") + + async with anyio.create_task_group() as group: + group.start_soon(compose_and_send, "a") + group.start_soon(compose_and_send, "b") + bodies = sorted( + (api.body(i)["subject"], api.body(i)["to"], r.headers["idempotency-key"]) + for i, r in enumerate(api.requests) + ) + assert bodies == [("a", ["a@example.test"], "key-a"), ("b", ["b@example.test"], "key-b")] + + +@pytest.mark.anyio +async def test_async_send_batch_and_ping(api: MockAPI, aclient: AsyncLettermint) -> None: + api.json(202, [PENDING]) + assert await aclient.emails.send_batch([aclient.emails.compose(MESSAGE)]) == [PENDING] + api.respond(lambda request: httpx.Response(200, text="pong")) + assert await aclient.emails.ping() == "pong" + + +def test_builders_compare_by_message(client: Lettermint) -> None: + a = client.emails.compose().to("x@example.test") + assert a == client.emails.compose().to("x@example.test") + assert a != a.to("y@example.test") + assert isinstance(a, EmailBuilder) + with pytest.raises(TypeError): + hash(a) diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 0000000..2ae90b0 --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,95 @@ +"""Query strings are serialized exactly like the Node.js SDK (``src/query.ts``).""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + +from lettermint import Lettermint +from lettermint._query import serialize_query, with_query_param + +from .conftest import MockAPI + +# The expected strings were produced by Node's serializeQuery (URLSearchParams). +CASES: list[tuple[dict[str, Any], str]] = [ + ( + { + "page": {"size": 30, "cursor": "abc"}, + "filter": {"status": "verified"}, + "sort": ["-created_at", "domain"], + }, + "page%5Bsize%5D=30&page%5Bcursor%5D=abc&filter%5Bstatus%5D=verified&sort=-created_at%2Cdomain", + ), + ( + { + "filter": { + "tags": [ + {"name": "campaign", "value": "welcome"}, + {"name": "plan", "value": "pro"}, + ], + "search": "a b+c&d=e", + } + }, + "filter%5Btags%5D%5B0%5D%5Bname%5D=campaign&filter%5Btags%5D%5B0%5D%5Bvalue%5D=welcome" + "&filter%5Btags%5D%5B1%5D%5Bname%5D=plan&filter%5Btags%5D%5B1%5D%5Bvalue%5D=pro&filter%5Bsearch%5D=a+b%2Bc%26d%3De", + ), + ( + {"filter": {"enabled": True, "is_default": False}, "include": ["dnsRecords"]}, + "filter%5Benabled%5D=1&filter%5Bis_default%5D=0&include=dnsRecords", + ), + ({"cursor": "x/y~z*", "page[size]": 10}, "cursor=x%2Fy%7Ez*&page%5Bsize%5D=10"), + ( + {"from": "2026-10-01", "to": "2026-10-31", "project_id": None, "include_machine": True}, + "from=2026-10-01&to=2026-10-31&include_machine=1", + ), + ( + {"filter": {"search": "Grüße ✓ 😀", "empty": [], "nulls": [None, "a", None]}}, + "filter%5Bsearch%5D=Gr%C3%BC%C3%9Fe+%E2%9C%93+%F0%9F%98%80&filter%5Bnulls%5D=a", + ), + ({"sort": [], "page": {}}, ""), + ({"weird": "!'()~*-._ :/?#[]@$,;"}, "weird=%21%27%28%29%7E*-._+%3A%2F%3F%23%5B%5D%40%24%2C%3B"), + ({"page": {"size": 30.0}}, "page%5Bsize%5D=30"), +] + + +@pytest.mark.parametrize(("query", "expected"), CASES) +def test_serialization_matches_node(query: dict[str, Any], expected: str) -> None: + assert serialize_query(query) == expected + + +def test_empty_queries() -> None: + assert serialize_query(None) == "" + assert serialize_query({}) == "" + + +def test_with_query_param() -> None: + query = {"page": {"size": 10}, "filter": {"status": "verified"}} + assert with_query_param(query, "page[cursor]", "c2") == { + "page": {"size": 10, "cursor": "c2"}, + "filter": {"status": "verified"}, + } + assert query == {"page": {"size": 10}, "filter": {"status": "verified"}} # unchanged + assert with_query_param(None, "cursor", "c2") == {"cursor": "c2"} + assert with_query_param({"page[cursor]": "old"}, "page[cursor]", "new") == { + "page": {"cursor": "new"} + } + + +def test_queries_reach_the_wire(api: MockAPI, client: Lettermint) -> None: + api.json(200, {"data": [], "next_cursor": None}) + client.domains.list( + {"page": {"size": 30}, "filter": {"status": "verified"}, "sort": ["-created_at"]} + ) + assert api.last.url.query == b"page%5Bsize%5D=30&filter%5Bstatus%5D=verified&sort=-created_at" + client.messages.list({"filter": {"tags": [{"name": "campaign", "value": "welcome"}]}}) + assert ( + api.last.url.query + == b"filter%5Btags%5D%5B0%5D%5Bname%5D=campaign&filter%5Btags%5D%5B0%5D%5Bvalue%5D=welcome" + ) + api.respond(lambda request: httpx.Response(200, json={"data": []})) + client.stats.retrieve({"from": "2026-10-01", "to": "2026-10-31", "include_machine": False}) + assert api.last.url.query == b"from=2026-10-01&to=2026-10-31&include_machine=0" + client.domains.retrieve("d1", {"include": ["dnsRecords"]}) + assert api.last.url.query == b"include=dnsRecords" diff --git a/tests/test_surface.py b/tests/test_surface.py new file mode 100644 index 0000000..8fd8f9e --- /dev/null +++ b/tests/test_surface.py @@ -0,0 +1,379 @@ +"""The public surface: every generated operation is reachable, the layout matches the +other SDKs, and the synchronous and asynchronous clients are the same API.""" + +from __future__ import annotations + +import inspect +import re +import typing +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import httpx +import pytest + +import lettermint +from lettermint import AsyncLettermint, Lettermint, UnexpectedResponseError +from lettermint import types as lm_types +from lettermint._generated.operations import OPERATIONS + +from .conftest import BASE_URL, MESSAGE, MockAPI + +# (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"), + ("blocked_file_types", (), "GET /blocked-file-types"), + ("emails.send", (MESSAGE,), "POST /send"), + ("emails.send_batch", ([MESSAGE],), "POST /send/batch"), + ("emails.ping", (), "GET /ping"), + ("domains.list", (), "GET /domains"), + ("domains.iterate", (), "GET /domains"), + ("domains.create", ({"domain": "acme.test"},), "POST /domains"), + ("domains.retrieve", ("d1",), "GET /domains/{domainId}"), + ("domains.delete", ("d1",), "DELETE /domains/{domainId}"), + ("domains.verify_dns_records", ("d1",), "POST /domains/{domainId}/dns-records/verify"), + ( + "domains.verify_dns_record", + ("d1", "r1"), + "POST /domains/{domainId}/dns-records/{recordId}/verify", + ), + ("domains.update_projects", ("d1", {"project_ids": []}), "PUT /domains/{domainId}/projects"), + ("messages.list", (), "GET /messages"), + ("messages.iterate", (), "GET /messages"), + ("messages.retrieve", ("m1",), "GET /messages/{messageId}"), + ("messages.events", ("m1",), "GET /messages/{messageId}/events"), + ("messages.iterate_events", ("m1",), "GET /messages/{messageId}/events"), + ("messages.source", ("m1",), "GET /messages/{messageId}/source"), + ("messages.html", ("m1",), "GET /messages/{messageId}/html"), + ("messages.text", ("m1",), "GET /messages/{messageId}/text"), + ( + "messages.reschedule", + ("m1", {"scheduled_at": "2026-10-20T09:00:00Z"}), + "PATCH /messages/{messageId}", + ), + ("messages.cancel", ("m1",), "POST /messages/{messageId}/cancel"), + ("messages.process", ("m1",), "POST /messages/{messageId}/process"), + ("projects.list", (), "GET /projects"), + ("projects.iterate", (), "GET /projects"), + ("projects.create", ({"name": "Production"},), "POST /projects"), + ("projects.retrieve", ("p1",), "GET /projects/{projectId}"), + ("projects.update", ("p1", {"name": "Renamed"}), "PUT /projects/{projectId}"), + ("projects.delete", ("p1",), "DELETE /projects/{projectId}"), + ("projects.rotate_token", ("p1",), "POST /projects/{projectId}/rotate-token"), + ("projects.report_forwarding.retrieve", ("p1",), "GET /projects/{projectId}/report-forwarding"), + ( + "projects.report_forwarding.update", + ("p1", {"email": "dmarc@acme.test"}), + "PUT /projects/{projectId}/report-forwarding", + ), + ( + "projects.report_forwarding.delete", + ("p1",), + "DELETE /projects/{projectId}/report-forwarding", + ), + ( + "projects.report_forwarding.verify", + ("p1", {"code": "123456"}), + "POST /projects/{projectId}/report-forwarding/verify", + ), + ( + "projects.report_forwarding.resend_code", + ("p1",), + "POST /projects/{projectId}/report-forwarding/resend-code", + ), + ("routes.list", ("p1",), "GET /projects/{projectId}/routes"), + ("routes.iterate", ("p1",), "GET /projects/{projectId}/routes"), + ( + "routes.create", + ("p1", {"name": "Broadcast", "route_type": "broadcast"}), + "POST /projects/{projectId}/routes", + ), + ("routes.retrieve", ("r1",), "GET /routes/{routeId}"), + ("routes.update", ("r1", {"name": "Renamed"}), "PUT /routes/{routeId}"), + ("routes.delete", ("r1",), "DELETE /routes/{routeId}"), + ("routes.verify_inbound_domain", ("r1",), "POST /routes/{routeId}/verify-inbound-domain"), + ("stats.retrieve", ({"from": "2026-10-01", "to": "2026-10-31"},), "GET /stats"), + ("suppressions.list", (), "GET /suppressions"), + ("suppressions.iterate", (), "GET /suppressions"), + ( + "suppressions.create", + ({"value": "x@example.test", "reason": "manual"},), + "POST /suppressions", + ), + ("suppressions.delete", ("s1",), "DELETE /suppressions/{suppressionId}"), + ("team.retrieve", (), "GET /team"), + ("team.update", ({"name": "Acme"},), "PUT /team"), + ("team.usage", (), "GET /team/usage"), + ("team.roles", (), "GET /team/roles"), + ("team.members.list", (), "GET /team/members"), + ("team.members.iterate", (), "GET /team/members"), + ("team.members.retrieve", ("u1",), "GET /team/members/{userId}"), + ( + "team.members.update_assignment", + ("u1", {"role": "admin"}), + "PUT /team/members/{userId}/assignment", + ), + ("webhooks.list", (), "GET /webhooks"), + ("webhooks.iterate", (), "GET /webhooks"), + ( + "webhooks.create", + ({"url": "https://acme.test/hook", "events": ["message.delivered"]},), + "POST /webhooks", + ), + ("webhooks.retrieve", ("w1",), "GET /webhooks/{webhookId}"), + ("webhooks.update", ("w1", {"enabled": False}), "PUT /webhooks/{webhookId}"), + ("webhooks.delete", ("w1",), "DELETE /webhooks/{webhookId}"), + ("webhooks.test", ("w1",), "POST /webhooks/{webhookId}/test"), + ("webhooks.regenerate_secret", ("w1",), "POST /webhooks/{webhookId}/regenerate-secret"), + ("webhooks.deliveries.list", ("w1",), "GET /webhooks/{webhookId}/deliveries"), + ("webhooks.deliveries.iterate", ("w1",), "GET /webhooks/{webhookId}/deliveries"), + ( + "webhooks.deliveries.retrieve", + ("w1", "dl1"), + "GET /webhooks/{webhookId}/deliveries/{deliveryId}", + ), +] + +LAYOUT = { + "domains": [ + "create", + "delete", + "iterate", + "list", + "retrieve", + "update_projects", + "verify_dns_record", + "verify_dns_records", + ], + "messages": [ + "cancel", + "events", + "html", + "iterate", + "iterate_events", + "list", + "process", + "reschedule", + "retrieve", + "source", + "text", + ], + "projects": ["create", "delete", "iterate", "list", "retrieve", "rotate_token", "update"], + "projects.report_forwarding": ["delete", "resend_code", "retrieve", "update", "verify"], + "routes": [ + "create", + "delete", + "iterate", + "list", + "retrieve", + "update", + "verify_inbound_domain", + ], + "stats": ["retrieve"], + "suppressions": ["create", "delete", "iterate", "list"], + "team": ["retrieve", "roles", "update", "usage"], + "team.members": ["iterate", "list", "retrieve", "update_assignment"], + "webhooks": [ + "create", + "delete", + "iterate", + "list", + "regenerate_secret", + "retrieve", + "test", + "update", + ], + "webhooks.deliveries": ["iterate", "list", "retrieve"], + "emails": ["compose", "ping", "send", "send_batch"], + "": ["analytics", "blocked_file_types", "close", "ping"], +} + + +def respond_for(request: httpx.Request) -> httpx.Response: + """A minimal valid answer for any operation.""" + path = request.url.path.removeprefix("/v1") + for key, operation in OPERATIONS.items(): + method, template = key.split(" ", 1) + if method == request.method and re.fullmatch(re.sub(r"\{\w+\}", "[^/]+", template), path): + if operation.response.type == "empty": + return httpx.Response(204) + if operation.response.type == "text": + return httpx.Response(200, text="pong") + if operation.pagination: + return httpx.Response(200, json={"data": [{"id": "x"}], "next_cursor": None}) + return httpx.Response(200, json={}) + return httpx.Response(404, json={"message": "no such route"}) + + +def operation_of(request: httpx.Request) -> str: + path = request.url.path.removeprefix("/v1") + for key in OPERATIONS: + method, template = key.split(" ", 1) + if method == request.method and re.fullmatch(re.sub(r"\{\w+\}", "[^/]+", template), path): + return key + raise AssertionError(f"no operation for {request.method} {path}") + + +def resolve(root: Any, dotted: str) -> Any: + target = root + for part in dotted.split("."): + target = getattr(target, part) + return target + + +def test_every_operation_is_reachable_from_the_sync_client( + api: MockAPI, client: Lettermint +) -> None: + api.respond(respond_for) + reached: set[str] = set() + for dotted, args, key in CALLS: + before = len(api.requests) + result = resolve(client, dotted)(*args) + if isinstance(result, Iterator): + assert list(result) == [{"id": "x"}] + assert [operation_of(r) for r in api.requests[before:]] == [key], dotted + reached.add(key) + assert reached == set(OPERATIONS) + + +@pytest.mark.anyio +async def test_every_operation_is_reachable_from_the_async_client( + api: MockAPI, aclient: AsyncLettermint +) -> None: + api.respond(respond_for) + reached: set[str] = set() + for dotted, args, key in CALLS: + before = len(api.requests) + result = resolve(aclient, dotted)(*args) + if isinstance(result, AsyncIterator): + assert [item async for item in result] == [{"id": "x"}] + else: + await result + assert [operation_of(r) for r in api.requests[before:]] == [key], dotted + reached.add(key) + assert reached == set(OPERATIONS) + + +def _public_methods(obj: Any) -> list[str]: + return sorted( + name + for name, value in inspect.getmembers(type(obj)) + if not name.startswith("_") and callable(value) + ) + + +@pytest.mark.parametrize("cls", [Lettermint, AsyncLettermint]) +def test_layout_matches_the_other_sdks(cls: Any) -> None: + client = cls(sending_token="lm_abc", team_token="lm_team_abc") + for group, methods in LAYOUT.items(): + target = resolve(client, group) if group else client + assert _public_methods(target) == methods, group + + +def test_sync_and_async_clients_have_the_same_signatures() -> None: + sync = Lettermint(sending_token="lm_abc", team_token="lm_team_abc") + asynchronous = AsyncLettermint(sending_token="lm_abc", team_token="lm_team_abc") + for group in LAYOUT: + for name in LAYOUT[group]: + left = resolve(sync, f"{group}.{name}" if group else name) + right = resolve(asynchronous, f"{group}.{name}" if group else name) + assert str(inspect.signature(left)).replace("Async", "") == str( + inspect.signature(right) + ).replace("Async", ""), (group, name) + assert inspect.iscoroutinefunction(right) != ( + name in ("compose", "iterate", "iterate_events") + ), (group, name) + builder_methods = _public_methods(sync.emails.compose()) + assert builder_methods == _public_methods(asynchronous.emails.compose()) + assert "send" in builder_methods and "build" in builder_methods and "from_" in builder_methods + + +def test_return_annotations_match_the_operation_table() -> None: + client = Lettermint(sending_token="lm_abc", team_token="lm_team_abc") + for dotted, _, key in CALLS: + if dotted in ("ping", "emails.ping"): + continue + hints = typing.get_type_hints(resolve(client, dotted)) + operation = OPERATIONS[key] + returned = hints["return"] + if dotted.endswith(("iterate", "iterate_events")): + assert operation.pagination is not None + assert typing.get_args(returned) == (getattr(lm_types, operation.pagination.items),), ( + dotted + ) + elif operation.response.type == "text": + assert returned is str, dotted + elif operation.response.type == "empty": + assert returned is type(None), dotted + else: + assert returned == getattr(lm_types, operation.response.type), dotted + + +def test_pagination_follows_cursors_and_stops_on_a_repeat(api: MockAPI, client: Lettermint) -> None: + pages = { + None: {"data": [1, 2], "next_cursor": "c2"}, + "c2": {"data": [3], "next_cursor": "c3"}, + "c3": {"data": [4], "next_cursor": "c2"}, + } + api.respond( + lambda request: httpx.Response(200, json=pages[request.url.params.get("page[cursor]")]) + ) + items: list[Any] = list( + client.domains.iterate({"page": {"size": 2}, "filter": {"status": "verified"}}) + ) + assert items == [1, 2, 3, 4] + assert [r.url.params.get("page[cursor]") for r in api.requests] == [None, "c2", "c3"] + assert all( + r.url.params["page[size]"] == "2" and r.url.params["filter[status]"] == "verified" + for r in api.requests + ) + + +def test_pagination_is_lazy_and_uses_the_cursor_parameter_of_the_operation( + api: MockAPI, client: Lettermint +) -> None: + api.respond( + lambda request: httpx.Response( + 200, json={"data": ["a"], "next_cursor": "n" + (request.url.params.get("cursor") or "")} + ) + ) + iterator: Iterator[Any] = client.webhooks.iterate() + assert api.requests == [] + assert [next(iterator), next(iterator)] == ["a", "a"] + assert [r.url.params.get("cursor") for r in api.requests] == [None, "n"] + + +def test_pagination_rejects_pages_without_data(api: MockAPI, client: Lettermint) -> None: + api.json(200, {"items": []}) + with pytest.raises( + UnexpectedResponseError, + match="domains.iterate: the API returned a page without a data array", + ): + list(client.domains.iterate()) + + +def test_top_level_exports_do_not_collide_with_generated_types() -> None: + assert not set(lettermint.__all__) & (set(lm_types.__all__) - {"CursorPage"}) + assert set(lettermint.__all__) == { + name for name in dir(lettermint) if not name.startswith("_") + } - { + "exceptions", + "webhook", + } | {"__version__"} + + +def test_email_message_mirrors_the_generated_request() -> None: + assert lettermint.EmailMessage.__required_keys__ == lm_types.SendMailRequest.__required_keys__ + assert lettermint.EmailMessage.__optional_keys__ == lm_types.SendMailRequest.__optional_keys__ + + +def test_generated_header() -> None: + from pathlib import Path + + folder = Path(lettermint.__file__).parent / "_generated" + for name in ("__init__.py", "types.py", "operations.py"): + lines = (folder / name).read_text().splitlines() + assert lines[0] == "# Generated by lettermint/sdk-generator — do not edit." + assert lines[4] == "# Naming profile: next" + assert BASE_URL # the mock origin is never the real API diff --git a/tests/test_tooling.py b/tests/test_tooling.py new file mode 100644 index 0000000..04a3ac3 --- /dev/null +++ b/tests/test_tooling.py @@ -0,0 +1,61 @@ +"""The generated synchronous layer is current, and packaging declares what the code imports.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from importlib.metadata import requires +from pathlib import Path + +import lettermint + +ROOT = Path(__file__).resolve().parents[1] + + +def test_the_sync_layer_is_generated_from_the_async_layer() -> None: + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "unasync.py"), "--check"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_generated_headers() -> None: + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "generate.py"), "--check"], + capture_output=True, + text=True, + env={"LETTERMINT_SDK_GENERATOR": str(ROOT / "no-generator-here"), "PATH": ""}, + ) + assert result.returncode == 0, result.stderr + assert "Naming profile: next" in result.stdout + + +def test_runtime_dependencies_are_declared_for_every_python_version() -> None: + package_dir = Path(lettermint.__file__).parent + imported = set() + for path in package_dir.rglob("*.py"): + imported |= set( + re.findall( + r"^\s*(?:from|import) (typing_extensions|httpx|anyio)\b", + path.read_text(), + re.MULTILINE, + ) + ) + assert imported == {"typing_extensions", "httpx", "anyio"} + declared = [r for r in requires("lettermint") or [] if "extra ==" not in r] + for name in imported: + matching = [ + r for r in declared if re.match(rf"{name.replace('_', '[-_]')}\b", r, re.IGNORECASE) + ] + assert matching and ";" not in matching[0], ( + f"{name} must be an unconditional dependency, got {declared}" + ) + + +def test_the_version_comes_from_one_place() -> None: + text = (ROOT / "src" / "lettermint" / "_version.py").read_text() + assert re.search(r'^__version__ = "\d+\.\d+\.\d+.*"$', text, re.MULTILINE) + assert 'dynamic = ["version"]' in (ROOT / "pyproject.toml").read_text() diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..fdfd24c --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,572 @@ +"""Requests, decoding, errors, redirects, timeouts and cancellation, for both clients.""" + +from __future__ import annotations + +import email.utils +import socket +import threading +import time +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import anyio +import httpx +import pytest + +from lettermint import ( + APIConnectionError, + APIError, + APITimeoutError, + AsyncLettermint, + AuthenticationError, + ConflictError, + Lettermint, + LettermintConfigError, + LettermintError, + LettermintValidationError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + RedirectError, + ServerError, + UnexpectedResponseError, + ValidationError, +) + +from .conftest import BASE_URL, MESSAGE, SENDING_TOKEN, TEAM_TOKEN, MockAPI + +HTML_502 = ( + "502 Bad Gateway

502 Bad Gateway

" +) + + +def assert_detached(error: BaseException) -> None: + """No chained exception (which could hold the httpx request), no token anywhere.""" + assert error.__cause__ is None + assert error.__context__ is None + for text in (str(error), repr(error), repr(vars(error))): + assert SENDING_TOKEN not in text + assert TEAM_TOKEN not in text + + +# ---------------------------------------------------------------- headers and tokens + + +def test_emails_use_the_sending_token_only(api: MockAPI, client: Lettermint) -> None: + api.json(202, {"message_id": "m1", "status": "pending"}) + client.emails.send(MESSAGE) + assert api.last.method == "POST" + assert str(api.last.url) == f"{BASE_URL}/send" + assert api.last.headers["x-lettermint-token"] == SENDING_TOKEN + assert "authorization" not in api.last.headers + assert api.last.headers["content-type"] == "application/json" + + +def test_team_api_uses_the_team_token_only(api: MockAPI, client: Lettermint) -> None: + api.json(200, {"data": [], "next_cursor": None}) + client.domains.list() + assert api.last.headers["authorization"] == f"Bearer {TEAM_TOKEN}" + assert "x-lettermint-token" not in api.last.headers + + +def test_ping_prefers_the_team_token(api: MockAPI) -> None: + api.respond(lambda request: httpx.Response(200, text=" pong\n")) + both = Lettermint( + sending_token=SENDING_TOKEN, + team_token=TEAM_TOKEN, + base_url=BASE_URL, + http_client=api.sync_client(), + ) + assert both.ping() == "pong" + assert api.last.headers["authorization"] == f"Bearer {TEAM_TOKEN}" + assert both.emails.ping() == "pong" + assert api.last.headers["x-lettermint-token"] == SENDING_TOKEN + assert "authorization" not in api.last.headers + sending_only = Lettermint(SENDING_TOKEN, base_url=BASE_URL, http_client=api.sync_client()) + sending_only.ping() + assert api.last.headers["x-lettermint-token"] == SENDING_TOKEN + + +def test_reschedule_and_cancel_accept_either_token(api: MockAPI) -> None: + api.json(200, {"message_id": "m1", "status": "canceled", "scheduled_at": None}) + sending_only = Lettermint(SENDING_TOKEN, base_url=BASE_URL, http_client=api.sync_client()) + sending_only.messages.cancel("m1") + assert api.last.headers["x-lettermint-token"] == SENDING_TOKEN + sending_only.messages.reschedule("m1", {"scheduled_at": "2026-10-20T09:00:00Z"}) + assert api.last.method == "PATCH" + team = Lettermint( + sending_token=SENDING_TOKEN, + team_token=TEAM_TOKEN, + base_url=BASE_URL, + http_client=api.sync_client(), + ) + team.messages.cancel("m1") + assert api.last.headers["authorization"] == f"Bearer {TEAM_TOKEN}" + + +def test_a_missing_token_is_a_config_error_that_names_the_option(api: MockAPI) -> None: + sending_only = Lettermint(SENDING_TOKEN, base_url=BASE_URL, http_client=api.sync_client()) + with pytest.raises( + LettermintConfigError, + match=r"^domains\.list needs team_token; pass Lettermint\(team_token=\.\.\.\)\.$", + ): + sending_only.domains.list() + with pytest.raises(LettermintConfigError, match=r"domains\.iterate needs team_token"): + sending_only.domains.iterate() + team_only = Lettermint(TEAM_TOKEN, base_url=BASE_URL, http_client=api.sync_client()) + with pytest.raises(LettermintConfigError, match=r"emails\.send needs sending_token"): + team_only.emails.send(MESSAGE) + with pytest.raises(LettermintConfigError, match=r"emails\.compose needs sending_token"): + team_only.emails.compose() + with pytest.raises(LettermintConfigError, match=r"emails\.ping needs sending_token"): + team_only.emails.ping() + assert api.requests == [] + + +# ---------------------------------------------------------------- paths and bodies + + +def test_path_parameters_are_encoded(api: MockAPI, client: Lettermint) -> None: + client.domains.retrieve("a/b c?d") + assert api.last.url.raw_path == b"/v1/domains/a%2Fb%20c%3Fd" + + +@pytest.mark.parametrize("value", ["", ".", "..", None, 42]) +def test_invalid_path_parameters_are_rejected_before_the_request( + api: MockAPI, client: Lettermint, value: Any +) -> None: + with pytest.raises( + LettermintConfigError, + match=r'domains\.retrieve: domainId must be a non-empty string other than "\." and "\.\."', + ): + client.domains.retrieve(value) + with pytest.raises(LettermintConfigError, match=r"webhooks\.deliveries\.iterate: webhookId"): + client.webhooks.deliveries.iterate(value) + assert api.requests == [] + + +def test_a_body_that_is_not_json_is_rejected(api: MockAPI, client: Lettermint) -> None: + with pytest.raises( + LettermintValidationError, + match=r"domains\.create: the request body cannot be encoded as JSON", + ): + client.domains.create({"domain": float("nan")}) # type: ignore[typeddict-item] + assert api.requests == [] + + +# ---------------------------------------------------------------- decoding + + +def test_204_returns_none(api: MockAPI, client: Lettermint) -> None: + api.respond(lambda request: httpx.Response(204)) + delete: Any = client.projects.report_forwarding.delete + assert delete("p1") is None + + +def test_text_endpoints_return_strings(api: MockAPI, client: Lettermint) -> None: + api.respond( + lambda request: httpx.Response( + 200, text="

Hi

", headers={"content-type": "text/html; charset=UTF-8"} + ) + ) + assert client.messages.html("m1") == "

Hi

" + api.respond( + lambda request: httpx.Response( + 200, + content="Grüße".encode("latin-1"), + headers={"content-type": "text/plain; charset=ISO-8859-1"}, + ) + ) + assert client.messages.text("m1") == "Grüße" + api.respond(lambda request: httpx.Response(200, content=b"Received: x\r\n")) + assert client.messages.source("m1") == "Received: x\r\n" + + +def test_unknown_enum_values_and_fields_are_kept(api: MockAPI, client: Lettermint) -> None: + api.json( + 202, + {"message_id": "m1", "status": "some_future_status", "some_future_field": {"nested": [1]}}, + ) + # The status of a send is a discriminator (Literal), but the raw value is kept. + result: dict[str, Any] = dict(client.emails.send(MESSAGE)) + assert result["status"] == "some_future_status" + assert result["some_future_field"] == {"nested": [1]} + + +@pytest.mark.parametrize( + ("response", "status", "message"), + [ + ( + httpx.Response(202, headers={"content-type": "application/json"}), + 202, + "an empty body where JSON was expected", + ), + (httpx.Response(202, text=" \n"), 202, "an empty body where JSON was expected"), + (httpx.Response(200, text="pong"), 200, "a body that is not valid JSON"), + ( + httpx.Response( + 502, text=HTML_502, headers={"content-type": "text/html; charset=UTF-8"} + ), + 502, + r"HTTP 502 and a body that is not JSON \(text/html\)", + ), + (httpx.Response(101), 101, "unexpected HTTP status 101"), + ], +) +def test_unexpected_responses_are_typed( + api: MockAPI, client: Lettermint, response: httpx.Response, status: int, message: str +) -> None: + api.respond(lambda request: response) + with pytest.raises(UnexpectedResponseError, match=message) as caught: + client.emails.send(MESSAGE) + assert caught.value.status == status + assert_detached(caught.value) + + +def test_unexpected_response_keeps_a_short_excerpt(api: MockAPI, client: Lettermint) -> None: + api.respond(lambda request: httpx.Response(502, text="x" * 500)) + with pytest.raises(UnexpectedResponseError) as caught: + client.emails.send(MESSAGE) + assert caught.value.body_excerpt == "x" * 200 + "…" + + +@pytest.mark.parametrize( + ("status", "cls"), + [ + (400, APIError), + (401, AuthenticationError), + (403, PermissionDeniedError), + (404, NotFoundError), + (409, ConflictError), + (410, APIError), + (422, ValidationError), + (429, RateLimitError), + (500, ServerError), + (503, ServerError), + ], +) +def test_api_errors_map_to_classes( + api: MockAPI, client: Lettermint, status: int, cls: type[APIError] +) -> None: + body = { + "error": {"code": "some_code", "message": "Something went wrong", "details": {"field": "x"}} + } + api.json(status, body) + with pytest.raises(cls) as caught: + client.emails.send(MESSAGE) + error = caught.value + assert type(error) is cls + assert isinstance(error, APIError) and isinstance(error, LettermintError) + assert (error.status, error.code, error.message, error.details, error.body) == ( + status, + "some_code", + "Something went wrong", + {"field": "x"}, + body, + ) + assert str(error) == "Something went wrong" + assert_detached(error) + + +def test_laravel_validation_errors(api: MockAPI, client: Lettermint) -> None: + body = {"message": "The to field is required.", "errors": {"to": ["The to field is required."]}} + api.json(422, body) + with pytest.raises(ValidationError) as caught: + client.emails.send(MESSAGE) + assert caught.value.message == "The to field is required." + assert caught.value.errors == {"to": ["The to field is required."]} + assert caught.value.code is None + + +def test_string_error_codes_and_reason_phrase_fallback(api: MockAPI, client: Lettermint) -> None: + api.json(400, {"error": "DailyLimitExceeded"}) + with pytest.raises(APIError) as caught: + client.emails.send(MESSAGE) + assert (caught.value.code, caught.value.message) == ("DailyLimitExceeded", "Bad Request") + api.respond(lambda request: httpx.Response(500)) + with pytest.raises(ServerError) as server: + client.emails.send(MESSAGE) + assert (server.value.message, server.value.body) == ("Internal Server Error", None) + + +def test_retry_after_seconds_and_http_dates(api: MockAPI, client: Lettermint) -> None: + api.json(429, {"message": "Too Many Attempts."}, **{"Retry-After": "17"}) + with pytest.raises(RateLimitError) as caught: + client.emails.send(MESSAGE) + assert caught.value.retry_after == 17 + later = email.utils.formatdate(time.time() + 120, usegmt=True) + api.json(429, {}, **{"Retry-After": later}) + with pytest.raises(RateLimitError) as dated: + client.emails.send(MESSAGE) + assert dated.value.retry_after is not None and 115 <= dated.value.retry_after <= 121 + api.json(429, {}, **{"Retry-After": "soon"}) + with pytest.raises(RateLimitError) as unparsable: + client.emails.send(MESSAGE) + assert unparsable.value.retry_after is None + + +def test_errors_survive_pickling(api: MockAPI, client: Lettermint) -> None: + import pickle + + api.json(429, {"message": "slow down"}, **{"Retry-After": "3"}) + with pytest.raises(RateLimitError) as caught: + client.emails.send(MESSAGE) + copy = pickle.loads(pickle.dumps(caught.value)) + assert (type(copy), copy.status, copy.retry_after, str(copy)) == ( + RateLimitError, + 429, + 3, + "slow down", + ) + + +# ---------------------------------------------------------------- redirects + + +@pytest.mark.parametrize("status", [301, 302, 303, 307, 308]) +def test_redirects_are_never_followed(api: MockAPI, status: int) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.host == "foreign.test": + return httpx.Response(202, json={"message_id": "captured", "status": "pending"}) + return httpx.Response(status, headers={"Location": "https://foreign.test/v1/send"}) + + api.respond(handler) + # Even a client configured to follow redirects does not follow them for the SDK. + client = Lettermint( + sending_token=SENDING_TOKEN, + team_token=TEAM_TOKEN, + base_url=BASE_URL, + http_client=api.sync_client(follow_redirects=True), + ) + with pytest.raises(RedirectError) as caught: + client.emails.send(MESSAGE) + assert caught.value.status == status + with pytest.raises(RedirectError): + client.ping() + assert {request.url.host for request in api.requests} == {"api.lettermint.test"} + assert_detached(caught.value) + + +@pytest.mark.anyio +async def test_async_redirects_are_never_followed(api: MockAPI) -> None: + api.respond( + lambda request: httpx.Response(307, headers={"Location": "https://foreign.test/v1/ping"}) + ) + client = AsyncLettermint( + TEAM_TOKEN, base_url=BASE_URL, http_client=api.async_client(follow_redirects=True) + ) + with pytest.raises(RedirectError) as caught: + await client.ping() + assert caught.value.status == 307 + assert len(api.requests) == 1 + + +# ---------------------------------------------------------------- connection errors + + +def test_connection_errors_are_wrapped_without_the_request( + api: MockAPI, client: Lettermint +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError( + f"refused, header was {request.headers['x-lettermint-token']}", request=request + ) + + api.respond(handler) + with pytest.raises(APIConnectionError) as caught: + client.emails.send(MESSAGE) + assert ( + str(caught.value) + == "emails.send: could not reach the Lettermint API (ConnectError: refused, header was [redacted])" + ) + assert_detached(caught.value) + + +@pytest.mark.anyio +async def test_async_connection_errors_are_wrapped_without_the_request( + api: MockAPI, aclient: AsyncLettermint +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.RemoteProtocolError("Server disconnected", request=request) + + api.respond(handler) + with pytest.raises( + APIConnectionError, match="RemoteProtocolError: Server disconnected" + ) as caught: + await aclient.domains.list() + assert_detached(caught.value) + + +def _closed_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def test_a_refused_connection_is_a_connection_error() -> None: + client = Lettermint(SENDING_TOKEN, base_url=f"http://127.0.0.1:{_closed_port()}/v1", timeout=5) + with pytest.raises(APIConnectionError) as caught: + client.emails.ping() + assert_detached(caught.value) + client.close() + + +@pytest.mark.anyio +async def test_async_refused_connection_is_a_connection_error() -> None: + async with AsyncLettermint( + SENDING_TOKEN, base_url=f"http://127.0.0.1:{_closed_port()}/v1", timeout=5 + ) as client: + with pytest.raises(APIConnectionError) as caught: + await client.emails.ping() + assert_detached(caught.value) + + +def test_exceptions_from_application_hooks_propagate_unchanged(api: MockAPI) -> None: + def hook(request: httpx.Request) -> None: + raise KeyError("from the application") + + client = Lettermint( + SENDING_TOKEN, + base_url=BASE_URL, + http_client=api.sync_client(event_hooks={"request": [hook]}), + ) + with pytest.raises(KeyError, match="from the application"): + client.emails.ping() + + +# ---------------------------------------------------------------- timeouts + + +class SlowStream(httpx.SyncByteStream, httpx.AsyncByteStream): + """A body that arrives in small pieces, each well within httpx's read timeout.""" + + def __init__(self, pieces: int, delay: float) -> None: + self.pieces = pieces + self.delay = delay + + def __iter__(self) -> Iterator[bytes]: + yield b'{"message_id": "m1",' + for _ in range(self.pieces): + time.sleep(self.delay) + yield b" " + yield b'"status": "pending"}' + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield b'{"message_id": "m1",' + for _ in range(self.pieces): + await anyio.sleep(self.delay) + yield b" " + yield b'"status": "pending"}' + + +def test_the_timeout_covers_a_slow_body(api: MockAPI) -> None: + api.respond(lambda request: httpx.Response(202, stream=SlowStream(pieces=40, delay=0.05))) + client = Lettermint( + SENDING_TOKEN, base_url=BASE_URL, timeout=0.5, http_client=api.sync_client() + ) + started = time.monotonic() + with pytest.raises(APITimeoutError) as caught: + client.emails.send(MESSAGE) + assert time.monotonic() - started < 1.5 + assert caught.value.timeout == 0.5 + assert str(caught.value) == "The request to the Lettermint API timed out after 0.5 seconds." + assert_detached(caught.value) + + +def test_the_timeout_covers_slow_headers(api: MockAPI) -> None: + released = threading.Event() + + def handler(request: httpx.Request) -> httpx.Response: + released.wait(5) + return httpx.Response(202, json={"message_id": "m1", "status": "pending"}) + + api.respond(handler) + client = Lettermint( + SENDING_TOKEN, base_url=BASE_URL, timeout=0.3, http_client=api.sync_client() + ) + started = time.monotonic() + with pytest.raises(APITimeoutError): + client.emails.send(MESSAGE) + assert time.monotonic() - started < 1.5 + released.set() + + +def test_a_per_call_timeout_overrides_the_client_timeout(api: MockAPI) -> None: + api.respond(lambda request: httpx.Response(202, stream=SlowStream(pieces=4, delay=0.05))) + client = Lettermint( + SENDING_TOKEN, base_url=BASE_URL, timeout=0.05, http_client=api.sync_client() + ) + assert client.emails.send(MESSAGE, timeout=5)["message_id"] == "m1" + with pytest.raises(LettermintConfigError, match="timeout must be a positive number"): + client.emails.send(MESSAGE, timeout=-1) + + +@pytest.mark.anyio +async def test_async_timeout_covers_a_slow_body(api: MockAPI) -> None: + api.respond(lambda request: httpx.Response(202, stream=SlowStream(pieces=40, delay=0.05))) + client = AsyncLettermint( + SENDING_TOKEN, base_url=BASE_URL, timeout=0.5, http_client=api.async_client() + ) + started = time.monotonic() + with pytest.raises(APITimeoutError) as caught: + await client.emails.send(MESSAGE) + assert time.monotonic() - started < 1.5 + assert_detached(caught.value) + + +@pytest.mark.anyio +async def test_async_cancellation_is_not_converted(api: MockAPI) -> None: + started = anyio.Event() + + async def handler(request: httpx.Request) -> httpx.Response: + started.set() + await anyio.sleep(10) + return httpx.Response(200, text="pong") + + api.respond(handler) + client = AsyncLettermint(TEAM_TOKEN, base_url=BASE_URL, http_client=api.async_client()) + outcome: list[str] = [] + + async def ping() -> None: + try: + await client.ping() + except LettermintError: + outcome.append("sdk error") + raise + except BaseException: + outcome.append("cancelled") + raise + + async with anyio.create_task_group() as group: + group.start_soon(ping) + await started.wait() + group.cancel_scope.cancel() + assert outcome == ["cancelled"] + + +# ---------------------------------------------------------------- idempotency + + +def test_idempotency_keys_are_per_call(api: MockAPI, client: Lettermint) -> None: + api.json(202, {"message_id": "m1", "status": "pending"}) + client.emails.send(MESSAGE, idempotency_key="key-1") + assert api.last.headers["idempotency-key"] == "key-1" + client.emails.send(MESSAGE) + assert "idempotency-key" not in api.last.headers + client.messages.process("m1", idempotency_key="process-1") + assert api.last.headers["idempotency-key"] == "process-1" + + +@pytest.mark.parametrize("key", ["", "a\nb", "a\rb", "a\0b", 42]) +def test_invalid_idempotency_keys_are_rejected(api: MockAPI, client: Lettermint, key: Any) -> None: + with pytest.raises(LettermintValidationError) as caught: + client.emails.send(MESSAGE, idempotency_key=key) + assert caught.value.field == "idempotency_key" + assert api.requests == [] + + +def test_no_automatic_retries(api: MockAPI, client: Lettermint) -> None: + api.json(503, {"message": "down"}) + with pytest.raises(ServerError): + client.emails.send(MESSAGE, idempotency_key="k") + assert len(api.requests) == 1 diff --git a/tests/test_webhook.py b/tests/test_webhook.py new file mode 100644 index 0000000..e34c680 --- /dev/null +++ b/tests/test_webhook.py @@ -0,0 +1,259 @@ +"""Webhook verification.""" + +from __future__ import annotations + +import email.message +import hashlib +import hmac +import json +import pickle +import time +from typing import Any + +import httpx +import pytest + +from lettermint import LettermintConfigError, Webhook, WebhookVerificationError + +SECRET = "whsec_test0123456789abcdefABCDEF0123" +BODY = json.dumps( + { + "id": "d1", + "event": "message.delivered", + "timestamp": "2026-10-03T12:00:00Z", + "data": {"message_id": "m1", "subject": "Grüße"}, + }, + ensure_ascii=False, + separators=(",", ":"), +).encode("utf-8") + + +def sign(body: bytes, timestamp: int, secret: str = SECRET) -> str: + return hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest() + + +def headers(timestamp: int, *signatures: str, delivery: str | None = None) -> dict[str, str]: + value = ",".join([f"t={timestamp}"] + [f"v1={s}" for s in signatures]) + return { + "X-Lettermint-Signature": value, + "X-Lettermint-Delivery": delivery if delivery is not None else str(timestamp), + } + + +@pytest.fixture +def now() -> int: + return int(time.time()) + + +def reason_of(webhook: Webhook, body: Any, request_headers: Any) -> str: + with pytest.raises(WebhookVerificationError) as caught: + webhook.verify(body, request_headers) + assert SECRET not in str(caught.value) and SECRET not in repr(caught.value) + return caught.value.reason + + +def test_a_genuine_delivery_verifies(now: int) -> None: + payload = Webhook(SECRET).verify(BODY, headers(now, sign(BODY, now))) + assert payload["event"] == "message.delivered" + assert payload["data"]["subject"] == "Grüße" + assert payload["id"] == "d1" + + +def test_str_and_bytes_bodies(now: int) -> None: + webhook = Webhook(SECRET) + signature = sign(BODY, now) + assert webhook.verify(BODY.decode("utf-8"), headers(now, signature))["id"] == "d1" + assert webhook.verify(bytearray(BODY), headers(now, signature))["id"] == "d1" + assert webhook.verify(memoryview(BODY), headers(now, signature))["id"] == "d1" + + +def test_any_v1_signature_may_match(now: int) -> None: + webhook = Webhook(SECRET) + good, bad = sign(BODY, now), "0" * 64 + assert webhook.verify(BODY, headers(now, bad, good)) + assert webhook.verify(BODY, headers(now, good, bad)) + assert reason_of(webhook, BODY, headers(now, bad, "1" * 64)) == "signature_mismatch" + assert webhook.verify( + BODY, + {"x-lettermint-signature": f"t={now},v0=abc,v1={good}", "x-lettermint-delivery": str(now)}, + ) + + +@pytest.mark.parametrize( + ("make_headers", "reason"), + [ + (lambda now, sig: {"X-Lettermint-Delivery": str(now)}, "signature_header_missing"), + ( + lambda now, sig: {"X-Lettermint-Signature": f"t={now},v1={sig}"}, + "delivery_header_missing", + ), + (lambda now, sig: headers(now, sig, delivery=str(now + 1)), "delivery_timestamp_mismatch"), + (lambda now, sig: headers(now, sig, delivery="abc"), "delivery_timestamp_mismatch"), + ( + lambda now, sig: { + **headers(now, sig), + "X-Lettermint-Signature": f"t={now},t={now},v1={sig}", + }, + "signature_header_malformed", + ), + ( + lambda now, sig: {**headers(now, sig), "X-Lettermint-Signature": f"v1={sig}"}, + "signature_header_malformed", + ), + ( + lambda now, sig: {**headers(now, sig), "X-Lettermint-Signature": f"t={now}"}, + "signature_header_malformed", + ), + ( + lambda now, sig: {**headers(now, sig), "X-Lettermint-Signature": f"t=abc,v1={sig}"}, + "signature_header_malformed", + ), + ( + lambda now, sig: { + **headers(now, sig), + "X-Lettermint-Signature": f"t={now},v1={sig[:-1]}é", + }, + "signature_header_malformed", + ), + ( + lambda now, sig: {**headers(now, sig), "X-Lettermint-Signature": f"t=123,v1={sig}"}, + "signature_header_malformed", + ), + ( + lambda now, sig: { + **headers(now, sig), + "X-Lettermint-Signature": "t=" + "9" * 5000 + f",v1={sig}", + }, + "signature_header_malformed", + ), + ( + lambda now, sig: {**headers(now, sig), "X-Lettermint-Signature": " "}, + "signature_header_missing", + ), + ( + lambda now, sig: [ + ("X-Lettermint-Signature", f"t={now},v1={sig}"), + ("x-lettermint-signature", f"t={now},v1={sig}"), + ("X-Lettermint-Delivery", str(now)), + ], + "signature_header_malformed", + ), + ( + lambda now, sig: [ + ("X-Lettermint-Signature", f"t={now},v1={sig}"), + ("X-Lettermint-Delivery", str(now)), + ("X-Lettermint-Delivery", str(now)), + ], + "delivery_timestamp_mismatch", + ), + ( + lambda now, sig: {"X-Lettermint-Signature": 42, "X-Lettermint-Delivery": str(now)}, + "signature_header_malformed", + ), + ], +) +def test_invalid_headers(now: int, make_headers: Any, reason: str) -> None: + assert reason_of(Webhook(SECRET), BODY, make_headers(now, sign(BODY, now))) == reason + + +def test_tolerance_in_both_directions(now: int) -> None: + webhook = Webhook(SECRET, tolerance=300) + for offset in (-300, 300, 0): + t = now + offset + assert webhook.verify(BODY, headers(t, sign(BODY, t))) + for offset in (-301, 301): + t = now + offset + assert reason_of(webhook, BODY, headers(t, sign(BODY, t))) == "timestamp_out_of_tolerance" + strict = Webhook(SECRET, tolerance=0) + assert ( + reason_of(strict, BODY, headers(now - 2, sign(BODY, now - 2))) + == "timestamp_out_of_tolerance" + ) + + +def test_the_body_is_signed_as_raw_bytes(now: int) -> None: + webhook = Webhook(SECRET) + signature = sign(BODY, now) + reserialized = json.dumps(json.loads(BODY)).encode() + assert reason_of(webhook, reserialized, headers(now, signature)) == "signature_mismatch" + assert reason_of(webhook, BODY + b" ", headers(now, signature)) == "signature_mismatch" + assert reason_of(webhook, json.loads(BODY), headers(now, signature)) == "body_invalid" + assert reason_of(webhook, b"", headers(now, signature)) == "body_invalid" + + +def test_the_secret_is_used_as_given(now: int) -> None: + stripped = SECRET.removeprefix("whsec_") + assert reason_of(Webhook(stripped), BODY, headers(now, sign(BODY, now))) == "signature_mismatch" + assert ( + reason_of(Webhook(SECRET + "x"), BODY, headers(now, sign(BODY, now))) + == "signature_mismatch" + ) + + +def test_signed_payloads_must_be_json_objects(now: int) -> None: + for body in (b"not json", b"[1, 2]", b"\xff\xfe"): + assert reason_of(Webhook(SECRET), body, headers(now, sign(body, now))) == "payload_invalid" + + +def test_header_containers(now: int) -> None: + webhook = Webhook(SECRET) + signature = sign(BODY, now) + plain = headers(now, signature) + message = email.message.Message() + for key, value in plain.items(): + message[key] = value + containers: list[Any] = [ + {key.lower(): value for key, value in plain.items()}, + {key.upper(): value for key, value in plain.items()}, + {key: [value] for key, value in plain.items()}, + list(plain.items()), + [ + (key.lower().encode(), value.encode()) for key, value in plain.items() + ], # raw ASGI headers + httpx.Headers(plain), + message, + ] + for container in containers: + assert webhook.verify(BODY, container)["id"] == "d1", type(container) + + +def test_misused_headers_argument() -> None: + with pytest.raises(TypeError, match="verify_signature"): + Webhook(SECRET).verify(BODY, "t=1,v1=abc") # type: ignore[arg-type] + + +def test_verify_signature(now: int) -> None: + webhook = Webhook(SECRET) + signature = f"t={now},v1={sign(BODY, now)}" + assert webhook.verify_signature(BODY, signature)["id"] == "d1" + assert webhook.verify_signature(BODY, signature, now)["id"] == "d1" + assert webhook.verify_signature(BODY, signature, f" {now} ")["id"] == "d1" + with pytest.raises(WebhookVerificationError) as caught: + webhook.verify_signature(BODY, signature, now + 1) + assert caught.value.reason == "delivery_timestamp_mismatch" + + +@pytest.mark.parametrize( + ("args", "message"), + [ + (("",), "signing secret must be a non-empty string"), + ((None,), "signing secret must be a non-empty string"), + ((SECRET, -1), "tolerance must be a non-negative whole number"), + ((SECRET, 1.5), "tolerance must be a non-negative whole number"), + ((SECRET, True), "tolerance must be a non-negative whole number"), + ], +) +def test_configuration_errors(args: tuple[Any, ...], message: str) -> None: + with pytest.raises(LettermintConfigError, match=message): + Webhook(*args) + + +def test_the_secret_never_shows(now: int) -> None: + webhook = Webhook(SECRET, tolerance=60) + assert repr(webhook) == "Webhook(tolerance=60)" == str(webhook) + assert webhook.tolerance == 60 + assert SECRET not in repr(webhook._secret) + with pytest.raises(TypeError): + pickle.dumps(webhook) + with pytest.raises(TypeError): + vars(webhook) From 95d21616793ed208d7c960e06c87f321639375be Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sat, 3 Oct 2026 23:59:57 +0200 Subject: [PATCH 5/8] ci: test Python 3.10 to 3.14, check generated code and the built wheel; set the release version in _version.py --- .github/workflows/ci.yml | 32 +++++++++++++++++++--------- .github/workflows/fix-code-style.yml | 4 ++-- .github/workflows/release.yml | 21 ++++++++++++------ 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c630d2..8178598 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,8 @@ jobs: strategy: fail-fast: true matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + # 3.10 is the oldest Python that still receives security fixes (3.9 reached end of life in October 2025). + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Checkout code @@ -36,10 +37,17 @@ jobs: pip install -e ".[dev]" - name: Run linting - run: python -m ruff check src/lettermint + run: | + python -m ruff check src tests scripts + python -m ruff format --check src tests scripts - name: Run type checking - run: python -m mypy src/lettermint --strict + run: python -m mypy + + - name: Check the generated code + run: | + python scripts/unasync.py --check + python scripts/generate.py --check - name: Run tests run: python -m pytest tests/ -v --tb=short @@ -62,18 +70,22 @@ jobs: - name: Install build dependencies run: | python -m pip install --upgrade pip - pip install build + pip install build twine - name: Build package run: python -m build - - name: Check build output + - name: Check the package run: | - if [ ! -d "dist" ]; then - echo "Build output directory 'dist' not found" - exit 1 - fi - ls -la dist/ + python -m twine check --strict dist/* + python -m venv /tmp/smoke + /tmp/smoke/bin/pip install dist/*.whl + cd /tmp && /tmp/smoke/bin/python - <<'PY' + import importlib.resources, lettermint + assert (importlib.resources.files("lettermint") / "UPGRADE.md").is_file(), "UPGRADE.md is not in the wheel" + assert (importlib.resources.files("lettermint") / "py.typed").is_file() + print("lettermint", lettermint.__version__, repr(lettermint.Lettermint("lm_smoke"))) + PY - name: Upload build artifacts uses: actions/upload-artifact@v7 diff --git a/.github/workflows/fix-code-style.yml b/.github/workflows/fix-code-style.yml index 9852b6c..0843cb1 100644 --- a/.github/workflows/fix-code-style.yml +++ b/.github/workflows/fix-code-style.yml @@ -28,8 +28,8 @@ jobs: - name: Fix code style issues run: | - python -m ruff check --fix src/lettermint || true - python -m ruff format src/lettermint || true + python -m ruff check --fix src tests scripts || true + python -m ruff format src tests scripts || true - name: Commit changes uses: stefanzweifel/git-auto-commit-action@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c591682..efb2ce3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,13 +49,13 @@ jobs: pip install -e ".[dev]" pip install build - - name: Update version in pyproject.toml - run: | - sed -i "s/^version = .*/version = \"${{ steps.version.outputs.version }}\"/" pyproject.toml - - - name: Update version in __init__.py + # pyproject.toml reads the version from src/lettermint/_version.py (hatch dynamic version). + - name: Set the version from the tag + env: + VERSION: ${{ steps.version.outputs.version }} run: | - sed -i "s/^__version__ = .*/__version__ = \"${{ steps.version.outputs.version }}\"/" src/lettermint/__init__.py + sed -i "s/^__version__ = .*/__version__ = \"${VERSION}\"/" src/lettermint/_version.py + grep -qx "__version__ = \"${VERSION}\"" src/lettermint/_version.py - name: Run tests run: python -m pytest tests/ -v @@ -63,5 +63,14 @@ jobs: - name: Build package run: python -m build + - name: Check the built version + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + ls dist/ + normalized=$(python -c 'import sys; from packaging.version import Version; print(Version(sys.argv[1]))' "$VERSION") + test -f "dist/lettermint-${normalized}-py3-none-any.whl" + test -f "dist/lettermint-${normalized}.tar.gz" + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 From 89a1379d309ed16b2fb006f6055f944663c82d5c Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sun, 4 Oct 2026 00:03:24 +0200 Subject: [PATCH 6/8] docs: rewrite the README for 3.0 and add the 2.x upgrade guide UPGRADE.md has a before/after for every changed call, the full type rename table, the removed classes and types, and a ready-to-copy instruction for upgrading with a coding agent. The wheel ships UPGRADE.md inside the package. --- README.md | 533 +++++++++++++++----------- UPGRADE.md | 630 ++++++++++++++++++++++++++++++- examples/async_send.py | 29 ++ examples/send_email.py | 21 ++ examples/webhook_verification.py | 24 ++ 5 files changed, 1013 insertions(+), 224 deletions(-) create mode 100644 examples/async_send.py create mode 100644 examples/send_email.py create mode 100644 examples/webhook_verification.py diff --git a/README.md b/README.md index 3a18b8a..d690ecd 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ [![License](https://img.shields.io/github/license/lettermint/lettermint-python?style=flat-square)](https://github.com/lettermint/lettermint-python/blob/main/LICENSE) [![Join our Discord server](https://img.shields.io/discord/1305510095588819035?logo=discord&logoColor=eee&label=Discord&labelColor=464ce5&color=0D0E28&cacheSeconds=43200)](https://lettermint.co/r/discord) -Official Python SDK for the [Lettermint](https://lettermint.co) sending and team APIs. +The official Python SDK for [Lettermint](https://lettermint.co). It runs on Python 3.10 and newer, is fully typed, and has a synchronous and an asynchronous client with the same methods. + +Upgrading from 2.x? Read [UPGRADE.md](UPGRADE.md). ## Installation @@ -15,344 +17,429 @@ Official Python SDK for the [Lettermint](https://lettermint.co) sending and team pip install lettermint ``` -## Quick Start +## Quick start -### Sending Emails (Synchronous) +Create a client with a project sending token and send an email: ```python +import os + from lettermint import Lettermint -email = Lettermint.email("your-sending-token") +lettermint = Lettermint(sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"]) -response = ( - email - .from_("sender@example.com") - .to("recipient@example.com") - .subject("Hello from Python!") - .html("

Welcome!

") - .text("Welcome!") - .send() -) +result = lettermint.emails.send({ + "from": "Acme ", + "to": ["jane@example.com"], + "subject": "Welcome to Acme", + "html": "

Thanks for signing up.

", + "text": "Thanks for signing up.", +}) -print(response["message_id"]) +print(result["message_id"], result["status"]) # "…", "pending" ``` -### Sending Emails (Asynchronous) +The asynchronous client works the same way, for `asyncio` and `trio`: ```python from lettermint import AsyncLettermint -email = AsyncLettermint.email("your-sending-token") - -response = await ( - email - .from_("sender@example.com") - .to("recipient@example.com") - .subject("Hello from Python!") - .html("

Welcome!

") - .send() -) -print(response["message_id"]) +async with AsyncLettermint(sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"]) as lettermint: + result = await lettermint.emails.send({...}) ``` -The legacy constructor still works for sending-only usage: +## Tokens -```python -client = Lettermint(api_token="your-sending-token") -client.email.from_("sender@example.com").to("recipient@example.com").subject("Hello").send() -``` +Lettermint has two kinds of API tokens: -## Email Options +| Argument | Token | Used by | Sent as | +| --- | --- | --- | --- | +| `sending_token` | Project sending token (`lm_…`) | `lettermint.emails` | `x-lettermint-token` header | +| `team_token` | Team API token (`lm_team_…`) | Every other part (domains, messages, projects, …) | `Authorization: Bearer` header | -### Multiple Recipients +Pass one or both: ```python -client.email.from_("sender@example.com").to( - "recipient1@example.com", - "recipient2@example.com" -).subject("Hello").send() +lettermint = Lettermint( + sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"], + team_token=os.environ["LETTERMINT_TEAM_TOKEN"], +) ``` -### CC and BCC +Each part uses its own token and never falls back to the other one. If the token a method needs is missing, it raises `LettermintConfigError` that names the argument (`domains.list needs team_token; …`), before any request. `lettermint.ping()` uses the team token when it is set, otherwise the sending token. `messages.reschedule()` and `messages.cancel()` accept either token in the same way. + +You can also pass a single token string. The SDK chooses its type by the format: `lm_team_` followed by letters and digits is a team token, and `lm_` followed by letters and digits is a sending token. Any other value, such as an SSO verification token (`lm_sso_…`), raises `LettermintConfigError`; pass `sending_token=` or `team_token=` explicitly in that case. ```python -client.email.from_("sender@example.com").to("recipient@example.com").cc( - "cc1@example.com", - "cc2@example.com" -).bcc("bcc@example.com").subject("Hello").send() +lettermint = Lettermint(os.environ["LETTERMINT_TOKEN"]) +lettermint = Lettermint(os.environ["LETTERMINT_TOKEN"], timeout=10) # with options ``` -### Reply-To +Error messages never contain the token, and `repr(lettermint)` shows tokens as `[redacted]`. -```python -client.email.from_("sender@example.com").to("recipient@example.com").reply_to( - "reply@example.com" -).subject("Hello").send() -``` +### Options -### RFC 5322 Addresses +| Argument | Default | Description | +| --- | --- | --- | +| `sending_token` | | Project sending token. | +| `team_token` | | Team API token. | +| `base_url` | `https://api.lettermint.co/v1` | API base URL. | +| `timeout` | `30` | Request timeout in seconds. It covers the whole request, including the response body. Every method also takes `timeout=`. | +| `http_client` | a new `httpx.Client` / `httpx.AsyncClient` | Your own `httpx` client, for example with a proxy. The SDK never follows redirects with it and does not close it. | -```python -client.email.from_("John Doe ").to( - "Jane Doe " -).subject("Hello").send() -``` +The client holds no per-email state, so create it once and share it, also between threads or tasks. It owns a connection pool: call `close()` (`await lettermint.close()` for the async client) when you are done, or use it as a context manager. -### Attachments +## Sending email -```python -import base64 - -# Read and encode your file -with open("document.pdf", "rb") as f: - content = base64.b64encode(f.read()).decode() - -# Regular attachment -client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Your Document" -).attach("document.pdf", content).send() - -# Inline attachment (for embedding in HTML) -client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Welcome" -).html('').attach( - "logo.png", logo_content, "logo@example.com" -).send() -``` +### The email builder -### Custom Headers +`emails.compose()` returns an immutable builder. Every setter returns a new builder and leaves the original unchanged, so you can keep a base builder and reuse it, also across threads and tasks: ```python -client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Hello" -).headers({"X-Custom-Header": "value"}).send() +welcome = ( + lettermint.emails.compose() + .from_("Acme ") + .subject("Welcome to Acme") + .tags([{"name": "campaign", "value": "welcome"}]) +) + +welcome.to("jane@example.com").html("

Hi Jane

").send() +welcome.to("john@example.com").html("

Hi John

").send() ``` -### Metadata and Tags +When you build an email over several statements, keep the returned builder: ```python -from lettermint import MessageTag - -client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Hello" -).metadata({"campaign_id": "123", "user_id": "456"}).tag("welcome-campaign").tags([ - MessageTag(name="campaign", value="welcome"), - MessageTag(name="customer", value="new"), -]).send() +email = lettermint.emails.compose().from_("hello@acme.com").to(user.email).subject("Your invoice") +if user.accountant: + email = email.cc(user.accountant) +email.html(invoice_html).send() ``` -`tag()` remains available for the legacy single tag. `tags()` accepts typed -`MessageTag` values and the previous dictionary form. +Builder methods: -### Routing +| Method | Description | +| --- | --- | +| `from_(address)` | Sender, for example `Acme ` (`from` is a Python keyword). | +| `to(*addresses)`, `cc(...)`, `bcc(...)`, `reply_to(...)` | Replace the recipient list. | +| `subject(text)` | Subject line. | +| `html(html \| None)`, `text(text \| None)` | Bodies. `None` removes one. | +| `headers(mapping)` | Custom email headers. | +| `metadata(mapping)` | Data stored with the message, not added as headers. | +| `tags([{"name", "value"}])`, `tag(name \| None)` | Name/value tags, and the legacy single tag. | +| `route(slug)` | The route to send through. | +| `scheduled_at(when \| None)` | Delivery time: an aware `datetime`, ISO 8601, or English such as `tomorrow 9am`. | +| `settings({"track_opens", "track_clicks", "tls"})` | Per-email settings that override the route. | +| `sandbox_result(result)` | The result a Sandbox project simulates. | +| `attach(filename, content, *, content_type=None, content_id=None)` | Adds an attachment. | +| `send(*, idempotency_key=None, timeout=None)` | Sends a snapshot of the email. The builder can be sent again. | +| `build()` | Returns the message in the API's format. | -```python -client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Hello" -).route("my-route").send() -``` +`emails.compose(message)` starts a builder from an existing message. With `AsyncLettermint`, `send()` is a coroutine. -### Idempotency Key +### Plain dicts -Prevent duplicate sends when retrying failed requests: +`emails.send()` takes the message in the API's format (`reply_to`, `scheduled_at`, `sandbox_result`, …). It is typed as `EmailMessage`: ```python -client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Hello" -).idempotency_key("unique-request-id").send() +lettermint.emails.send({ + "from": "Acme ", + "to": ["jane@example.com"], + "reply_to": ["support@acme.com"], + "subject": "Your order has shipped", + "html": html, + "metadata": {"order_id": "1234"}, +}) ``` -### Batch Sending +### Batch sending + +Send up to 500 emails in one request. The list may mix messages and builders: ```python -email = Lettermint.email("your-sending-token") - -response = email.send_batch([ - { - "from": "sender@example.com", - "to": ["recipient@example.com"], - "subject": "Hello from Python!", - "text": "This is a batch email.", - } +results = lettermint.emails.send_batch([ + {"from": "hello@acme.com", "to": ["jane@example.com"], "subject": "Hi Jane", "text": "Hello"}, + welcome.to("john@example.com").html("

Hi John

"), ]) ``` -Both sync and async sending clients support `ping()`: +### Idempotency + +Pass an idempotency key to make retries safe. The API processes a key once, so a retry with the same key does not send the email again. The key applies only to the call it is passed to. ```python -email.ping() -await AsyncLettermint.email("your-sending-token").ping() +lettermint.emails.send(message, idempotency_key=f"order-{order.id}-confirmation") +builder.send(idempotency_key="welcome-jane") +lettermint.emails.send_batch(messages, idempotency_key="newsletter-2026-10") ``` -## Team API +The SDK never retries on its own. -Use a team API token with `Lettermint.api(...)`. API tokens authenticate with `Authorization: Bearer ...` and are separate from project sending tokens. +### Scheduling ```python -from lettermint import Lettermint +from datetime import datetime, timedelta, timezone + +result = ( + lettermint.emails.compose() + .from_("hello@acme.com") + .to("jane@example.com") + .subject("Your trial ends tomorrow") + .text("…") + .scheduled_at(datetime.now(timezone.utc) + timedelta(days=1)) + .send() +) -api = Lettermint.api("your-api-token") +if result["status"] == "scheduled": + print(result["scheduled_at"]) -domains = api.domains.list({"page[size]": "10"}) -team = api.team.retrieve() -message_html = api.messages.html("message-id") -pong = api.ping() +lettermint.messages.reschedule(result["message_id"], {"scheduled_at": "2026-10-20T09:00:00Z"}) +lettermint.messages.cancel(result["message_id"]) ``` -The async Team API client is available through `AsyncLettermint.api(...)`: +### Sandbox -```python -from lettermint import AsyncLettermint +In a Sandbox project, nothing is delivered. Choose the simulated result per email: -api = AsyncLettermint.api("your-api-token") +```python +result = ( + lettermint.emails.compose() + .from_("hello@acme.com") + .to("jane@example.com") + .subject("Test") + .text("Test") + .sandbox_result("hard_bounced") + .send() +) -domains = await api.domains.list({"page[size]": "10"}) -message_html = await api.messages.html("message-id") +print(result.get("sandbox"), result.get("sandbox_result")) # True, "hard_bounced" ``` -Endpoint groups are available as `domains`, `messages`, `projects`, `routes`, `stats`, `suppressions`, `team`, and `webhooks`. +### Tags -## Webhook Verification +`tags()` accepts up to 20 case-sensitive name/value tags (19 when the legacy `tag()` is also set). Names match `^[A-Za-z0-9_-]{1,32}$`, may not start with `__lettermint` and must be unique. Values match `^[A-Za-z0-9_-]{1,64}$`. The SDK checks this before the request and raises `LettermintValidationError`. Because builders are immutable, a rejected tag leaves the builder unchanged. -Verify webhook signatures to ensure authenticity: +### Attachments + +`content` is base64 text or bytes (`bytes`, `bytearray`, `memoryview`); the SDK base64-encodes bytes. ```python -from lettermint import Webhook +from pathlib import Path + +( + lettermint.emails.compose() + .from_("billing@acme.com") + .to("jane@example.com") + .subject("Your invoice") + .html(' Your invoice is attached.') + .attach("invoice.pdf", Path("invoice.pdf").read_bytes(), content_type="application/pdf") + .attach("logo.png", logo_base64, content_id="logo") + .send() +) +``` -# Create a webhook verifier -webhook = Webhook(secret="your-webhook-secret") +`lettermint.blocked_file_types()` lists the extensions and MIME types the API rejects. -# Verify using headers (recommended) -payload = webhook.verify_headers(request.headers, request.body) +## Team API -# Or verify using the signature directly -payload = webhook.verify( - payload=request.body, - signature=request.headers["X-Lettermint-Signature"], -) +With a team token, the client manages domains, messages, projects, routes, statistics, suppressions, the team and webhooks: -print(payload["event"]) -``` +```python +lettermint = Lettermint(team_token=os.environ["LETTERMINT_TEAM_TOKEN"]) + +domain = lettermint.domains.create({"domain": "acme.com"}) +lettermint.domains.verify_dns_records(domain["id"]) -### Static Method +project = lettermint.projects.create({"name": "Production"}) +print(project["api_token"]) # the new project's sending token, shown once -For one-off verification: +stats = lettermint.stats.retrieve({"from": "2026-10-01", "to": "2026-10-31"}) +html = lettermint.messages.html("message-id") +``` + +| Attribute | Methods | +| --- | --- | +| `domains` | `list`, `iterate`, `create`, `retrieve`, `delete`, `verify_dns_records`, `verify_dns_record`, `update_projects` | +| `messages` | `list`, `iterate`, `retrieve`, `events`, `iterate_events`, `source`, `html`, `text`, `reschedule`, `cancel`, `process` | +| `projects` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `rotate_token` | +| `projects.report_forwarding` | `retrieve`, `update`, `delete`, `verify`, `resend_code` | +| `routes` | `list(project_id)`, `iterate(project_id)`, `create(project_id, …)`, `retrieve`, `update`, `delete`, `verify_inbound_domain` | +| `stats` | `retrieve` | +| `suppressions` | `list`, `iterate`, `create`, `delete` | +| `team` | `retrieve`, `update`, `usage`, `roles` | +| `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` | + +### Query parameters and pagination + +Query parameters are typed nested dicts. The SDK sends them in the API's bracket syntax (`page[size]=30&filter[status]=verified&sort=-created_at`): ```python -from lettermint import Webhook +page = lettermint.domains.list({ + "page": {"size": 30}, + "filter": {"status": "verified"}, + "sort": ["-created_at"], +}) -payload = Webhook.verify_signature( - payload=request.body, - signature=request.headers["X-Lettermint-Signature"], - secret="your-webhook-secret", -) +print(len(page["data"]), page["next_cursor"]) ``` -### Custom Tolerance - -Adjust the timestamp tolerance (default: 300 seconds): +Every list has an `iterate()` method that follows `next_cursor` until the last page. It requests the next page only when you get to it, so you can stop early with `break`: ```python -webhook = Webhook(secret="your-webhook-secret", tolerance=600) +for message in lettermint.messages.iterate({"filter": {"status": "hard_bounced"}}): + print(message["id"], message["subject"]) + +async for delivery in async_lettermint.webhooks.deliveries.iterate(webhook_id): + ... ``` -## Error Handling +### Timeouts and cancellation -```python -from lettermint import Lettermint -from lettermint.exceptions import ( - ValidationError, - ClientError, - HttpRequestError, - TimeoutError, -) +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. -client = Lettermint(api_token="your-sending-token") +## Errors -try: - response = client.email.from_("sender@example.com").to("recipient@example.com").subject( - "Hello" - ).send() -except ValidationError as e: - # 422 errors (e.g., daily limit exceeded) - print(f"Validation error: {e.error_type}") - print(f"Response: {e.response_body}") -except ClientError as e: - # 400 errors - print(f"Client error: {e}") -except TimeoutError as e: - # Request timeout - print(f"Timeout: {e}") -except HttpRequestError as e: - # Other HTTP errors - print(f"HTTP error {e.status_code}: {e}") -``` +Every exception the SDK raises is a `LettermintError`: -### Webhook Errors +| Class | When | Attributes | +| --- | --- | --- | +| `APIError` | Any 4xx or 5xx response with a JSON (or empty) body | `status`, `code`, `message`, `details`, `body` | +| `AuthenticationError` | 401 | | +| `PermissionDeniedError` | 403 | | +| `NotFoundError` | 404 | | +| `ConflictError` | 409 | | +| `ValidationError` | 422 | `errors` (field errors) | +| `RateLimitError` | 429 | `retry_after` (seconds) | +| `ServerError` | 5xx | | +| `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` | +| `RedirectError` | A 3xx response. Redirects are never followed, so tokens never go elsewhere. | `status` | +| `LettermintConfigError` | A missing or unrecognised token, an invalid option or ID | | +| `LettermintValidationError` | The SDK rejected the request before sending it, such as invalid tags | `field` | +| `WebhookVerificationError` | A webhook delivery is not genuine | `reason` | + +The status classes are `APIError` subclasses. `code` and `message` come from the API's error body (`{"error": {"code", "message", "details"}}` or `{"message", "errors"}`). ```python -from lettermint import Webhook -from lettermint.exceptions import ( - InvalidSignatureError, - TimestampToleranceError, - JsonDecodeError, - WebhookVerificationError, -) +from lettermint import APIError, APITimeoutError, RateLimitError, ValidationError try: - payload = webhook.verify_headers(headers, body) -except InvalidSignatureError: - print("Invalid signature - request may be forged") -except TimestampToleranceError: - print("Timestamp too old - possible replay attack") -except JsonDecodeError: - print("Invalid JSON in payload") -except WebhookVerificationError as e: - print(f"Verification failed: {e}") + lettermint.emails.send(message, idempotency_key=key) +except ValidationError as error: + print(error.message, error.errors) +except RateLimitError as error: + time.sleep(error.retry_after or 1) # then retry with the same idempotency_key +except APITimeoutError: + ... # the outcome is unknown; retry with the same idempotency_key +except APIError as error: + print(error.status, error.code, error.message) ``` -## Configuration +`httpx` exceptions never escape: the SDK translates them, and its exceptions are not chained to them, because they hold the request and its headers. No exception contains a token. -### Custom Base URL +## Webhooks + +Verify each webhook delivery before you trust it. Use the webhook's signing secret (`whsec_…`), not an API token, and pass the **raw** request body: the signature covers the exact bytes, so parsing and re-serializing the JSON breaks it. ```python -client = Lettermint( - api_token="your-sending-token", - base_url="https://custom.api.com/v1", -) +from lettermint import Webhook, WebhookVerificationError + +webhook = Webhook(os.environ["LETTERMINT_WEBHOOK_SECRET"]) + +event = webhook.verify(raw_body, headers) +print(event["event"], event["data"]) ``` -### Custom Timeout +`verify(raw_body, headers)` takes the body as `str` or bytes, and the headers as any mapping (a `dict`, Django's `request.headers`, Flask, Starlette, `httpx.Headers`, an `email.message.Message`) or a list of `(name, value)` pairs, such as raw ASGI headers. It requires `X-Lettermint-Signature` and `X-Lettermint-Delivery` (header names are case-insensitive), checks the HMAC-SHA256 signature in constant time against every `v1` value, checks that the delivery timestamp equals the signed one and is within the tolerance, and returns the parsed payload. Otherwise it raises `WebhookVerificationError` with a `reason`: `signature_header_missing`, `signature_header_malformed`, `delivery_header_missing`, `delivery_timestamp_mismatch`, `timestamp_out_of_tolerance`, `signature_mismatch`, `body_invalid` or `payload_invalid`. + +### Flask ```python -client = Lettermint( - api_token="your-sending-token", - timeout=60.0, # 60 seconds -) +from flask import Flask, request + +app = Flask(__name__) +webhook = Webhook(os.environ["LETTERMINT_WEBHOOK_SECRET"]) + +@app.post("/webhooks/lettermint") +def lettermint_webhook(): + try: + event = webhook.verify(request.get_data(), request.headers) + except WebhookVerificationError: + return "Invalid signature", 400 + # Handle event["event"] and event["data"] here. + return "", 204 ``` -## Context Manager - -Both sync and async clients support context managers for proper resource cleanup: +### FastAPI and Starlette ```python -# Sync -with Lettermint(api_token="your-sending-token") as client: - client.email.from_("sender@example.com").to("recipient@example.com").send() +from fastapi import FastAPI, HTTPException, Request + +app = FastAPI() +webhook = Webhook(os.environ["LETTERMINT_WEBHOOK_SECRET"]) + +@app.post("/webhooks/lettermint", status_code=204) +async def lettermint_webhook(request: Request) -> None: + try: + event = webhook.verify(await request.body(), request.headers) + except WebhookVerificationError: + raise HTTPException(status_code=400, detail="Invalid signature") + # Handle event["event"] and event["data"] here. +``` + +### Django -# Async -async with AsyncLettermint(api_token="your-sending-token") as client: - await client.email.from_("sender@example.com").to("recipient@example.com").send() +```python +from django.http import HttpResponse, HttpResponseBadRequest +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +webhook = Webhook(os.environ["LETTERMINT_WEBHOOK_SECRET"]) + +@csrf_exempt +@require_POST +def lettermint_webhook(request): + try: + event = webhook.verify(request.body, request.headers) + except WebhookVerificationError: + return HttpResponseBadRequest("Invalid signature") + # Handle event["event"] and event["data"] here. + return HttpResponse(status=204) ``` -## Type Hints +### Options and lower-level verification + +The default tolerance is 300 seconds in either direction. Change it with `Webhook(secret, tolerance=60)`. `0` accepts only the current second; it does not disable the check. A valid signature does not prevent a repeated delivery within the tolerance, so track `event["id"]` if you must not process an event twice. -This SDK is fully typed with `py.typed` marker. You'll get full autocomplete and type checking in your IDE. +If the headers are not at hand, call `webhook.verify_signature(raw_body, signature_header, delivery_header)`. + +The payload is typed as `WebhookPayload`, with `event` as a `WebhookEvent` (`"message.delivered"`, `"message.hard_bounced"`, …). Unknown event names pass through as strings. + +## Types + +Request and response types are generated from the Lettermint API specification and live in `lettermint.types`, for example `SendMailRequest`, `SendMailResponse`, `DomainData`, `ListDomainsQuery` and `ListDomainsResponse` (a `CursorPage[DomainListData]`). Responses are plain dicts typed as `TypedDict`s: optional keys are `NotRequired`, nullable values include `None`. Enums are open (`Literal["pending", "delivered", ...] | str`), so values that the API adds later still type-check; give `match` statements a default case. The API's error bodies are `ApiErrorBody` and `ValidationErrorBody`. ## Requirements -- Python 3.9+ -- httpx +- Python 3.10, 3.11, 3.12, 3.13 or 3.14 (tested in CI). +- `httpx` (0.27 up to 1.0), `anyio` and `typing_extensions`. + +## Development + +```bash +pip install -e ".[dev]" +ruff check src tests scripts && ruff format --check src tests scripts +mypy +pytest +``` + +The synchronous client in `src/lettermint/_sync/` is generated from the asynchronous one in `src/lettermint/_async/`: edit the async code, then run `python scripts/unasync.py`. Everything that differs for real (sending requests, the deadline) is in `src/lettermint/_transport.py`, and the I/O-free core is shared. `python scripts/unasync.py --check` runs in CI. + +`src/lettermint/_generated/` is generated by the private [SDK generator](https://github.com/lettermint/sdk-generator). Do not edit it by hand. With a checkout of the generator, `python scripts/generate.py` regenerates the files and `python scripts/generate.py --check` verifies them; set `LETTERMINT_SDK_GENERATOR` to the checkout (default `../sdk-generator`). Without the generator, as in CI, `--check` only verifies the generated headers. ## License diff --git a/UPGRADE.md b/UPGRADE.md index 409ceb6..69ef94f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,4 +1,632 @@ -# Upgrade To v2 +# Upgrade guide + +- [Upgrade from 2.x to 3.0](#upgrade-from-2x-to-30) +- [Upgrade from 1.x to 2.0](#upgrade-to-v2) + +# Upgrade from 2.x to 3.0 + +2.x no longer receives updates, including fixes. Upgrade to 3.0 to keep getting them. + +3.0 is a new major version. The main reason is safety: in 2.x, `client.email` returned one cached, mutable builder per client. Two emails composed at the same time on one client (two threads, or two requests in a web app) could mix recipients, content and the `Idempotency-Key`, and an email abandoned halfway (for example because `tags()` raised) leaked into the next send. 3.0 stores nothing about a message on the client. + +## Highlights + +- One client per mode with the same methods: `Lettermint` and `AsyncLettermint`. They take `sending_token` and/or `team_token`, or one token string. They replace `Lettermint.email()`, `Lettermint.api()`, `ApiClient` and `AsyncApiClient`. +- Sending is stateless: `emails.send(message, idempotency_key=...)`, `emails.send_batch(messages, ...)` and an immutable `emails.compose()` builder. The `Idempotency-Key` is a per-call argument. +- Each part uses its own token: `emails` uses the sending token, the Team API the team token. The SDK never falls back to the other token. +- Typed exceptions for every outcome. `httpx` exceptions never escape, so no exception carries the request or its token. +- Redirects are never followed, so tokens are never sent to another host. The timeout covers the whole request, including the body. +- Tokens never appear in `repr()`, `vars()`, logs or pickles. +- Webhook verification requires both signature headers, accepts any `v1` signature, takes the body first and reports a `reason`. +- Typed nested query objects (`{"page": {"size": 30}}`) and `iterate()` generators that follow `next_cursor`. +- Types are generated from the current API specification and use its names (see [Type names](#type-names)). Enums are open: `Literal[...] | str`. +- Python 3.10 or newer. Python 3.9 reached end of life in October 2025. + +## Requirements + +- Python 3.10 or newer. +- `httpx` 0.27 or newer (below 1.0), `anyio` 3.5 or newer and `typing_extensions` 4.7 or newer. `anyio` already comes with `httpx`. + +## Upgrade with a coding agent + +You can let a coding agent (Claude Code, Codex, Cursor, Copilot, ...) do the upgrade. Copy this instruction into the agent from your project's root, then review its changes: + +````text +Upgrade this project from the `lettermint` Python SDK 2.x to 3.0. + +1. Install `lettermint>=3,<4` with the project's package manager (pip, uv, Poetry, PDM, ...) and update the lock file. 3.0 needs Python 3.10 or newer: check `requires-python`, CI workflows, Dockerfiles and runtime files, and report anything older. +2. Read the upgrade guide before changing code: `UPGRADE.md` inside the installed package (print its path with `python -c "import importlib.resources as r; print(r.files('lettermint') / 'UPGRADE.md')"`), or https://github.com/lettermint/lettermint-python/blob/main/UPGRADE.md. Treat it as the source of truth and don't guess APIs; when unsure, read the installed package (`lettermint/__init__.py`, `lettermint/_sync/`, `lettermint/types.py`). +3. Find every use of the SDK: `import lettermint`, `from lettermint`, `Lettermint(`, `AsyncLettermint(`, `Lettermint.email(`, `Lettermint.api(`, `api_token=`, `.email.`, `.idempotency_key(`, `.attach(`, `.send_batch(`, `Webhook`, `verify_headers(`, `verify_signature(`, `MessageTag`, `lettermint.exceptions`, the 2.x exception classes and the 2.x type names from the guide's type-name table. +4. Rewrite each use following the guide's before/after examples: + - Create one client with `Lettermint(sending_token=...)` (or `AsyncLettermint` in async code), adding `team_token=...` only where the Team API is used. Keep the project's existing environment variable names. Create it once and reuse it; close it at shutdown or use it as a context manager. + - Replace builder chains on `client.email` with `client.emails.send({...})`, or with `client.emails.compose()` per email. Builders are immutable: assign the result of every setter. Never keep a half-built email in module scope. + - Move idempotency keys into `send(..., idempotency_key=...)` / `send_batch(..., idempotency_key=...)`. Attachments: `.attach(filename, content, content_type=..., content_id=...)`, with keyword arguments; bytes are encoded by the SDK. + - Team API: use the same client (`client.domains`, ...), nested query dicts instead of `"page[size]"`-style keys, and the renamed methods from the guide. + - Errors: switch to the 3.0 classes (`APIError`, `ValidationError`, `RateLimitError`, `APITimeoutError`, `APIConnectionError`, ...). Rename `status_code` to `status` and `response_body` to `body`. + - Webhooks: `Webhook(secret).verify(raw_body, headers)`. Keep passing the raw request body, keep the secret's `whsec_` prefix, and make sure the `X-Lettermint-Signature` and `X-Lettermint-Delivery` headers reach the handler. + - Rename types using the guide's type-name table. +5. Run the type checker (mypy or pyright), the linter and the tests, and fix every error. Don't send real email or call the live API while testing. +6. Finish with a summary: the files you changed, anything you could not migrate with certainty, and behaviour changes I should review. + +Never print, log or commit API tokens or webhook secrets. +```` + +## Create the client + +`Lettermint.email()`, `Lettermint.api()`, `AsyncLettermint.email()`, `AsyncLettermint.api()`, `ApiClient`, `AsyncApiClient`, the `api_token` argument and the `client.email` attribute are removed. + +```python +# 2.x +from lettermint import Lettermint + +email = Lettermint.email(os.environ["LETTERMINT_PROJECT_TOKEN"], timeout=10.0) +api = Lettermint.api(os.environ["LETTERMINT_TEAM_TOKEN"]) +legacy = Lettermint(api_token=os.environ["LETTERMINT_PROJECT_TOKEN"]) + +# 3.0 +from lettermint import Lettermint + +lettermint = Lettermint( + sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"], # for lettermint.emails + team_token=os.environ["LETTERMINT_TEAM_TOKEN"], # for the Team API + timeout=10.0, +) +``` + +```python +# 2.x +from lettermint import AsyncLettermint + +email = AsyncLettermint.email(token) +api = AsyncLettermint.api(team_token) + +# 3.0 +from lettermint import AsyncLettermint + +lettermint = AsyncLettermint(sending_token=token, team_token=team_token) +``` + +Pass one token or both. With only one token, calling a part that needs the other raises `LettermintConfigError` (for example `domains.list needs team_token; pass Lettermint(team_token=...).`) before any request. + +You can also pass one token string as the first argument. The SDK picks the token type by its format: `lm_team_` followed by letters and digits is a team token, any other `lm_` followed by letters and digits is a sending token: + +```python +lettermint = Lettermint("lm_team_...") # team token +lettermint = Lettermint("lm_...") # project sending token +lettermint = Lettermint(token, timeout=10.0) # with options +``` + +Any other format (SSO tokens, OAuth tokens, an empty string) raises `LettermintConfigError`; pass `sending_token=` or `team_token=` for those. Passing a token string and `sending_token`/`team_token` together is also an error. + +`base_url` and `timeout` (seconds) work as before. `http_client` is new: an `httpx.Client` (or `httpx.AsyncClient` for `AsyncLettermint`) to send requests with, for example for a proxy. The SDK does not close a client it did not create. + +### Closing the client + +The client owns an HTTP connection pool. Create it once and share it; it holds no per-email state. `close()` and the context managers work as before, and close only the client itself (in 2.x, the context manager of an endpoint closed the shared HTTP client): + +```python +# 2.x +with Lettermint(api_token=token) as client: + client.email.from_("hello@acme.com").to("jane@example.com").subject("Hi").send() + +# 3.0 +with Lettermint(sending_token=token) as lettermint: + lettermint.emails.compose().from_("hello@acme.com").to("jane@example.com").subject("Hi").send() + +async with AsyncLettermint(sending_token=token) as lettermint: + await lettermint.emails.send({"from": "hello@acme.com", "to": ["jane@example.com"], "subject": "Hi"}) +``` + +After `close()`, calls raise `LettermintConfigError`. + +## Send an email + +The 2.x builder lived on the client and was reset after each send. In 3.0, `emails.compose()` returns an immutable builder: each setter returns a new builder and leaves the old one unchanged. Chaining works as before. If you built an email over several statements, assign the result of each setter. + +```python +# 2.x +client = Lettermint(api_token=token) +response = ( + client.email + .from_("Acme ") + .to("jane@example.com") + .subject("Welcome") + .html("

Hi Jane

") + .idempotency_key("welcome-jane") + .send() +) + +# 3.0: builder +response = ( + lettermint.emails.compose() + .from_("Acme ") + .to("jane@example.com") + .subject("Welcome") + .html("

Hi Jane

") + .send(idempotency_key="welcome-jane") +) + +# 3.0: a plain dict in the API's format +response = lettermint.emails.send( + {"from": "Acme ", "to": ["jane@example.com"], "subject": "Welcome", "html": "

Hi Jane

"}, + idempotency_key="welcome-jane", +) +``` + +```python +# 2.x: statements changed the shared builder +email = client.email +email.from_("hello@acme.com") +email.to("jane@example.com") +if copy: + email.cc("team@acme.com") +email.subject("Hi").send() + +# 3.0: keep the returned builder +draft = lettermint.emails.compose().from_("hello@acme.com").to("jane@example.com") +if copy: + draft = draft.cc("team@acme.com") +draft.subject("Hi").send() +``` + +A base builder can now be shared safely, also between threads and tasks: + +```python +welcome = lettermint.emails.compose().from_("Acme ").subject("Welcome") +welcome.to("jane@example.com").html(jane_html).send() +welcome.to("john@example.com").html(john_html).send(idempotency_key="welcome-john") +``` + +In `AsyncLettermint`, `send()` is a coroutine: `await builder.send()`. The setters are the same. + +### Changed builder methods + +| 2.x | 3.0 | +| --- | --- | +| `client.email.from_(x)` and the other setters change the client's builder and return it | Return a new builder: `lettermint.emails.compose().from_(x)` | +| `.idempotency_key(key).send()` | `.send(idempotency_key=key)` | +| `.attach(filename, content, content_id=None, content_type=None)` (base64 text) | `.attach(filename, content, *, content_type=None, content_id=None)`; `content` may also be `bytes`. `content_type` and `content_id` are keyword-only, so a 2.x call with positional `content_id` raises `TypeError` instead of swapping them. | +| `.html(None)`, `.text(None)` were ignored | `None` removes the field; so do `tag(None)` and `scheduled_at(None)` | +| `.scheduled_at(str)` | `.scheduled_at(str \| datetime)`; a datetime must be timezone-aware | +| `.tags()` / `.tag()` raised `ValueError` | They raise `LettermintValidationError` (with `field`) and leave the builder unchanged | +| `.tags([MessageTag(name=..., value=...)])` | `.tags([{"name": ..., "value": ...}])` | +| `.send()` | `.send(idempotency_key=None, timeout=None)`; the builder can be sent again | +| `.send_batch(payload)` on the builder | `lettermint.emails.send_batch(messages)` | +| `.ping()` on the builder | `lettermint.emails.ping()` | +| — | `.build()` returns the message in the API's format | + +Unchanged setters: `to`, `cc`, `bcc`, `reply_to` (each replaces its list), `subject`, `headers`, `metadata`, `route`, `settings`, `tag`, `sandbox_result`. + +Attachment content is base64-encoded by the SDK when you pass bytes: + +```python +# 2.x +content = base64.b64encode(pdf_bytes).decode() +client.email.attach("invoice.pdf", content, None, "application/pdf") +client.email.attach("logo.png", logo_base64, "logo") + +# 3.0 +builder = builder.attach("invoice.pdf", pdf_bytes, content_type="application/pdf") +builder = builder.attach("logo.png", logo_base64, content_id="logo") +``` + +### Tags + +`MessageTag` (the 2.x frozen dataclass that validated on construction) is removed. Tags are dicts, validated before the request by `tags()`, `send()` and `send_batch()`. `lettermint.types.MessageTag` is now the generated type of tags in responses, and `MessageTagInput` the type of tags you send; both are `TypedDict`s, so `MessageTag(name="campaign", value="welcome")` still builds the same dict. + +```python +# 2.x +from lettermint import MessageTag +client.email.tags([MessageTag(name="campaign", value="welcome")]) + +# 3.0 +builder = builder.tags([{"name": "campaign", "value": "welcome"}]) +``` + +## Batch sending and ping + +```python +# 2.x +Lettermint.email(token).idempotency_key("batch-1").send_batch([message1, message2]) +Lettermint.email(token).ping() +Lettermint.api(team_token).ping() + +# 3.0 +lettermint.emails.send_batch([message1, message2], idempotency_key="batch-1") +lettermint.emails.send_batch([builder1, builder2]) # builders work too +lettermint.emails.ping() # sending token +lettermint.ping() # team token if configured, otherwise the sending token +``` + +`send_batch()` validates the tags of every message and names the bad one (`messages[2].tags`). + +## Team API + +The sub-clients move from `Lettermint.api(token).x` to `lettermint.x`. Query parameters are nested dicts instead of `dict[str, str]` with bracket keys. Request bodies are the first argument after the IDs (named `body`, 2.x: `data`). Every list also has an `iterate()` generator that follows `next_cursor`, and every method takes a keyword-only `timeout`. + +```python +# 2.x +api = Lettermint.api(team_token) +page = api.domains.list({"page[size]": "10", "filter[status]": "verified"}) + +# 3.0 +lettermint = Lettermint(team_token=team_token) +page = lettermint.domains.list({"page": {"size": 10}, "filter": {"status": "verified"}}) +for domain in lettermint.domains.iterate({"filter": {"status": "verified"}}): + print(domain["domain"]) + +# 3.0, async +async for domain in async_lettermint.domains.iterate({"filter": {"status": "verified"}}): + print(domain["domain"]) +``` + +| 2.x (`api = Lettermint.api(token)`) | 3.0 (`lettermint = Lettermint(team_token=...)`) | +| --- | --- | +| `api.ping()` | `lettermint.ping()` | +| `api.blocked_file_types()` | `lettermint.blocked_file_types()` | +| `api.analytics(data)` | `lettermint.analytics(query)` | +| `api.close()`, `with api:` | `lettermint.close()`, `with lettermint:` | +| `api.domains.list(query)` | `lettermint.domains.list(query)`, `lettermint.domains.iterate(query)` | +| `api.domains.create(data)` | `lettermint.domains.create(body)` | +| `api.domains.retrieve(domain_id, query)` | `lettermint.domains.retrieve(domain_id, query)` (`{"include": ["dnsRecords"]}`) | +| `api.domains.delete(domain_id)` | `lettermint.domains.delete(domain_id)` | +| `api.domains.verify_dns_records(domain_id)` | `lettermint.domains.verify_dns_records(domain_id)` | +| `api.domains.verify_dns_record(domain_id, record_id)` | `lettermint.domains.verify_dns_record(domain_id, record_id)` | +| `api.domains.update_projects(domain_id, data)` | `lettermint.domains.update_projects(domain_id, body)` | +| `api.messages.list(query)` | `lettermint.messages.list(query)`, `lettermint.messages.iterate(query)` | +| `api.messages.retrieve(message_id, query)` | `lettermint.messages.retrieve(message_id)` (the endpoint takes no query) | +| `api.messages.events(message_id, query)` | `lettermint.messages.events(message_id, query)`, `lettermint.messages.iterate_events(message_id, query)` | +| `api.messages.source(id)` / `.html(id)` / `.text(id)` | unchanged, on `lettermint.messages` | +| `api.messages.reschedule(message_id, data)` | `lettermint.messages.reschedule(message_id, body)` | +| `api.messages.cancel(message_id)` | `lettermint.messages.cancel(message_id)` | +| `api.messages.process(message_id)` | `lettermint.messages.process(message_id, idempotency_key=None)` | +| `api.projects.list(query)` | `lettermint.projects.list(query)`, `lettermint.projects.iterate(query)` | +| `api.projects.create(data)` | `lettermint.projects.create(body)` | +| `api.projects.retrieve(project_id, query)` | `lettermint.projects.retrieve(project_id, query)` | +| `api.projects.update(project_id, data)` | `lettermint.projects.update(project_id, body)` | +| `api.projects.delete(project_id)` | `lettermint.projects.delete(project_id)` | +| `api.projects.rotate_token(project_id)` | `lettermint.projects.rotate_token(project_id)` (deprecated by the API) | +| `api.projects.routes(project_id, query)` | `lettermint.routes.list(project_id, query)`, `lettermint.routes.iterate(project_id, query)` | +| `api.projects.create_route(project_id, data)` | `lettermint.routes.create(project_id, body)` | +| `api.projects.retrieve_report_forwarding(project_id)` | `lettermint.projects.report_forwarding.retrieve(project_id)` | +| `api.projects.update_report_forwarding(project_id, data)` | `lettermint.projects.report_forwarding.update(project_id, body)` | +| `api.projects.delete_report_forwarding(project_id)` | `lettermint.projects.report_forwarding.delete(project_id)` | +| `api.projects.verify_report_forwarding(project_id, data)` | `lettermint.projects.report_forwarding.verify(project_id, body)` | +| `api.projects.resend_report_forwarding_code(project_id)` | `lettermint.projects.report_forwarding.resend_code(project_id)` | +| `api.routes.retrieve(route_id, query)` | `lettermint.routes.retrieve(route_id, query)` | +| `api.routes.update(route_id, data)` | `lettermint.routes.update(route_id, body)` | +| `api.routes.delete(route_id)` | `lettermint.routes.delete(route_id)` | +| `api.routes.verify_inbound_domain(route_id)` | `lettermint.routes.verify_inbound_domain(route_id)` | +| `api.stats.retrieve(query)` | `lettermint.stats.retrieve({"from": ..., "to": ..., "project_id": ..., "include_machine": ...})` (`from` and `to` are required) | +| `api.suppressions.list(query)` | `lettermint.suppressions.list(query)`, `lettermint.suppressions.iterate(query)` | +| `api.suppressions.create(data)` | `lettermint.suppressions.create(body)` | +| `api.suppressions.delete(suppression_id)` | `lettermint.suppressions.delete(suppression_id)` | +| `api.team.retrieve(query)` | `lettermint.team.retrieve(query)` (`{"include": ["features"]}`) | +| `api.team.update(data)` | `lettermint.team.update(body)` | +| `api.team.usage()` | `lettermint.team.usage()` | +| `api.team.roles()` | `lettermint.team.roles()` | +| `api.team.members(query)` | `lettermint.team.members.list(query)`, `lettermint.team.members.iterate(query)` | +| `api.team.member(user_id)` | `lettermint.team.members.retrieve(user_id)` | +| `api.team.update_member_assignment(user_id, data)` | `lettermint.team.members.update_assignment(user_id, body)` | +| `api.webhooks.list(query)` | `lettermint.webhooks.list(query)`, `lettermint.webhooks.iterate(query)` | +| `api.webhooks.create(data)` | `lettermint.webhooks.create(body)` | +| `api.webhooks.retrieve(webhook_id)` | `lettermint.webhooks.retrieve(webhook_id)` | +| `api.webhooks.update(webhook_id, data)` | `lettermint.webhooks.update(webhook_id, body)` | +| `api.webhooks.delete(webhook_id)` | `lettermint.webhooks.delete(webhook_id)` | +| `api.webhooks.test(webhook_id)` | `lettermint.webhooks.test(webhook_id)` | +| `api.webhooks.regenerate_secret(webhook_id)` | `lettermint.webhooks.regenerate_secret(webhook_id)` | +| `api.webhooks.deliveries(webhook_id, query)` | `lettermint.webhooks.deliveries.list(webhook_id, query)`, `lettermint.webhooks.deliveries.iterate(webhook_id, query)` | +| `api.webhooks.delivery(webhook_id, delivery_id)` | `lettermint.webhooks.deliveries.retrieve(webhook_id, delivery_id)` | + +The async client has the same methods; `await` them, and use `async for` with `iterate()`. + +`messages.reschedule()` and `messages.cancel()` accept either token: the team token when configured, otherwise the sending token. This lets a sending-only client cancel the scheduled email it sent. + +### Query parameters + +Write bracketed names as nested dicts. Lists of values are joined with commas, lists of dicts are indexed, booleans are sent as `1`/`0` and `None` values are left out. The query types (`ListDomainsQuery`, ...) are in `lettermint.types`. + +| 2.x | 3.0 | +| --- | --- | +| `{"page[size]": "30", "page[cursor]": c}` | `{"page": {"size": 30, "cursor": c}}` | +| `{"filter[status]": "verified"}` | `{"filter": {"status": "verified"}}` | +| `{"sort": "-created_at,domain"}` | `{"sort": ["-created_at", "domain"]}` | +| `{"filter[tags][0][name]": "a", "filter[tags][0][value]": "b"}` | `{"filter": {"tags": [{"name": "a", "value": "b"}]}}` | +| `{"filter[enabled]": "true"}` | `{"filter": {"enabled": True}}` | +| webhooks: `{"cursor": c}` | unchanged: `{"cursor": c}` (these lists use `cursor`, not `page[cursor]`) | + +### Path parameters + +IDs are still URL-encoded. An empty ID, `"."` or `".."` now raises `LettermintConfigError` before the request. + +### Message lists + +The 2.x types described message and event lists with a nested `meta` object. The API returns a flat cursor page, which 3.0 types as `CursorPage[T]`: read `page["next_cursor"]`, not `page["meta"]["next_cursor"]`. Or use `iterate()`. + +## Errors + +`HttpRequestError`, `ClientError`, `TimeoutError`, `InvalidSignatureError`, `TimestampToleranceError` and `JsonDecodeError` are removed. Every exception the SDK raises is a `LettermintError`, and raw `httpx` exceptions (and `json.JSONDecodeError`) no longer escape. The 3.0 classes avoid the names of Python's built-in `TimeoutError`, `ConnectionError` and `PermissionError`. + +| Situation | 2.x | 3.0 | +| --- | --- | --- | +| HTTP 400 | `ClientError` (`status_code`, `response_body`) | `APIError` (`status`, `code`, `message`, `details`, `body`) | +| HTTP 401 | `HttpRequestError` | `AuthenticationError` | +| HTTP 403 | `HttpRequestError` | `PermissionDeniedError` | +| HTTP 404 | `HttpRequestError` | `NotFoundError` | +| HTTP 409 | `HttpRequestError` | `ConflictError` | +| HTTP 422 | `ValidationError` (`status_code`, `error_type`, `response_body`) | `ValidationError` (`status`, `code`, `errors`, `body`) | +| HTTP 429 | `HttpRequestError` | `RateLimitError` (`retry_after` in seconds) | +| HTTP 5xx | `HttpRequestError` | `ServerError` | +| Other 4xx | `HttpRequestError` | `APIError` | +| 2xx with an empty or invalid JSON body | `json.JSONDecodeError` | `UnexpectedResponseError` (`status`, `body_excerpt`) | +| Error page that is not JSON (a proxy's HTML 502) | `HttpRequestError` | `UnexpectedResponseError` | +| Redirect (3xx) | followed, with the token | `RedirectError` (`status`); never followed | +| Timeout | `lettermint.TimeoutError` | `APITimeoutError` (`timeout`); covers the whole request | +| Network failure (DNS, TLS, refused, reset) | raw `httpx.ConnectError` and friends, carrying the request and its token header | `APIConnectionError`, not chained to the `httpx` exception | +| Invalid tags, idempotency key or body | `ValueError` | `LettermintValidationError` (`field`) | +| Missing or wrong token, bad option or ID | — | `LettermintConfigError` | + +Property renames: `status_code` → `status`, `response_body` → `body`, `error_type` → `code`. `code` comes from `{"error": {"code"}}`, or from a string `error` field. `message` is the API's message, or the HTTP reason phrase. The status classes (`AuthenticationError`, ..., `ServerError`) are `APIError` subclasses, so `except APIError` catches them all. + +```python +# 2.x +from lettermint.exceptions import ClientError, HttpRequestError, TimeoutError, ValidationError + +try: + client.email.from_(sender).to(recipient).subject("Hi").send() +except ValidationError as e: + print(e.status_code, e.error_type, e.response_body) +except HttpRequestError as e: + if e.status_code == 429: + retry_later() +except TimeoutError: + ... + +# 3.0 +from lettermint import APIError, APITimeoutError, RateLimitError, ValidationError + +try: + lettermint.emails.send(message, idempotency_key=key) +except ValidationError as e: + print(e.status, e.code, e.errors) +except RateLimitError as e: + retry_later(e.retry_after) +except APITimeoutError: + ... # the outcome is unknown; retry with the same idempotency_key +except APIError as e: + print(e.status, e.code, e.message) +``` + +The SDK does not retry requests. Pass an `idempotency_key` when you retry a send. Cancelling an `asyncio` or `trio` task cancels its request; the SDK lets the cancellation through unchanged. + +Exceptions can be pickled (for Celery or `multiprocessing`); clients, builders and webhook verifiers cannot, because they hold credentials. + +## Webhooks + +`verify()` now takes the raw body first and the request headers second, and replaces `verify_headers()`. The 2.x `verify(payload, signature, timestamp)` is now `verify_signature()`, an instance method. The static `Webhook.verify_signature(payload, signature, secret, ...)` is removed. + +```python +# 2.x +webhook = Webhook(secret) +payload = webhook.verify_headers(request.headers, request.body) +payload = webhook.verify(request.body, signature_header, int(delivery_header)) +payload = Webhook.verify_signature(request.body, signature_header, secret) + +# 3.0 +webhook = Webhook(secret) +event = webhook.verify(request.body, request.headers) +event = webhook.verify_signature(request.body, signature_header, delivery_header) +``` + +- The body may be `str` or bytes (`bytes`, `bytearray`, `memoryview`). Pass it raw; parsed JSON raises `WebhookVerificationError` with reason `body_invalid`. +- The headers may be any mapping (a `dict`, Django `request.headers`, Flask, Starlette, `httpx.Headers`, an `email.message.Message`) or `(name, value)` pairs such as the raw ASGI headers. Names are case-insensitive. +- Both `X-Lettermint-Signature` and `X-Lettermint-Delivery` are required, and the delivery header must equal the signed timestamp. 2.x accepted a delivery without `X-Lettermint-Delivery` in `verify()`. +- Any `v1` signature in the header may match (2.x checked only the last one), so key rotation works. +- The tolerance applies in both directions (`|now - t| <= tolerance`). +- Malformed or non-ASCII signature headers raise `WebhookVerificationError` instead of crashing. +- `WebhookVerificationError` has a `reason`: `signature_header_missing`, `signature_header_malformed`, `delivery_header_missing`, `delivery_timestamp_mismatch`, `timestamp_out_of_tolerance`, `signature_mismatch`, `body_invalid` or `payload_invalid`. `InvalidSignatureError`, `TimestampToleranceError` and `JsonDecodeError` map to `signature_mismatch`, `timestamp_out_of_tolerance` and `payload_invalid`. +- An empty secret or a negative or non-integer `tolerance` raises `LettermintConfigError` (2.x: `ValueError` for an empty secret). +- The return value is typed as `WebhookPayload` (`event`, `data`, `id`, `timestamp`; other keys are kept). +- `lettermint.webhook.SIGNATURE_HEADER` and `DELIVERY_HEADER` are now lower case. + +## Type names + +The types are generated from the API specification of lettermint#2582 and use its names. Import them from `lettermint.types`; the package root exports only `CursorPage`, `EmailMessage` and `EmailAttachment`. Types not listed below keep their name. Some shapes also changed: + +- Enums are open: `MessageStatus` is `Literal["pending", ...] | str`, so values the API adds later still type-check. Give `match` statements a default case. +- `SendMailResponse` is `PendingSendMailResponse | ScheduledSendMailResponse`; narrow on `status`. +- `MessageTag` describes tags in responses; `MessageTagInput` describes tags you send. +- Message and event lists are `CursorPage[T]` (see [Message lists](#message-lists)). +- Query parameters have types of their own, nested as the SDK sends them: `ListDomainsQuery`, `ListDomainsQueryPage`, `ListDomainsQueryFilter`, ... +- The API's error bodies are `ApiErrorBody` and `ValidationErrorBody`, because `APIError` and `ValidationError` are exception classes. +- `TypedDict`s set `__required_keys__` correctly at runtime. + +| 2.x (`lettermint.types`) | 3.0 (`lettermint.types`) | +| --- | --- | +| `AnalyticsRequest` | `AnalyticsQuery` | +| `AnalyticsRequestFiltersItem` | `AnalyticsFilter` | +| `AnalyticsRequestSort` | `AnalyticsSort` | +| `AnalyticsResponseMeta` | `AnalyticsMeta` | +| `AnalyticsResponseMetaComparison` | `AnalyticsMetaComparison` | +| `AnalyticsResponsePagination` | `AnalyticsPagination` | +| `AnalyticsResponsePayload` | `AnalyticsResults` | +| `AnalyticsResponsePayloadBreakdownItem` | `AnalyticsBreakdownRow` | +| `AnalyticsResponsePayloadBreakdownItemMetrics` | `AnalyticsMetricValues` | +| `AnalyticsResponsePayloadBreakdownItemPrevious` | `AnalyticsComparisonValues` | +| `AnalyticsResponsePayloadBreakdownItemPreviousMetrics` | `AnalyticsMetricValues` | +| `AnalyticsResponsePayloadBreakdownItemPreviousRateBases` | `AnalyticsRateBases` | +| `AnalyticsResponsePayloadBreakdownItemPreviousRateBasesBounceRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemPreviousRateBasesComplaintRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemPreviousRateBasesDeferralRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemPreviousRateBasesDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemPreviousRateBasesEffectiveDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemPreviousRateBasesHumanClickRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemPreviousRateBasesHumanOpenRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemRateBases` | `AnalyticsRateBases` | +| `AnalyticsResponsePayloadBreakdownItemRateBasesBounceRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemRateBasesComplaintRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemRateBasesDeferralRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemRateBasesDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemRateBasesEffectiveDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemRateBasesHumanClickRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemRateBasesHumanOpenRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItem` | `AnalyticsTimeSeriesPoint` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemMetrics` | `AnalyticsMetricValues` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemPrevious` | `AnalyticsComparisonValues` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemPreviousMetrics` | `AnalyticsMetricValues` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBases` | `AnalyticsRateBases` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesBounceRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesComplaintRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesDeferralRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesEffectiveDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesHumanClickRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemPreviousRateBasesHumanOpenRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemRateBases` | `AnalyticsRateBases` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesBounceRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesComplaintRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesDeferralRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesEffectiveDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesHumanClickRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadBreakdownItemTrendItemRateBasesHumanOpenRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummary` | `AnalyticsSummary` | +| `AnalyticsResponsePayloadSummaryMetrics` | `AnalyticsMetricValues` | +| `AnalyticsResponsePayloadSummaryPrevious` | `AnalyticsComparisonValues` | +| `AnalyticsResponsePayloadSummaryPreviousMetrics` | `AnalyticsMetricValues` | +| `AnalyticsResponsePayloadSummaryPreviousRateBases` | `AnalyticsRateBases` | +| `AnalyticsResponsePayloadSummaryPreviousRateBasesBounceRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryPreviousRateBasesComplaintRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryPreviousRateBasesDeferralRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryPreviousRateBasesDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryPreviousRateBasesEffectiveDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryPreviousRateBasesHumanClickRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryPreviousRateBasesHumanOpenRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryRateBases` | `AnalyticsRateBases` | +| `AnalyticsResponsePayloadSummaryRateBasesBounceRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryRateBasesComplaintRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryRateBasesDeferralRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryRateBasesDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryRateBasesEffectiveDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryRateBasesHumanClickRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadSummaryRateBasesHumanOpenRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItem` | `AnalyticsTimeSeriesPoint` | +| `AnalyticsResponsePayloadTimeSeriesItemMetrics` | `AnalyticsMetricValues` | +| `AnalyticsResponsePayloadTimeSeriesItemPrevious` | `AnalyticsComparisonValues` | +| `AnalyticsResponsePayloadTimeSeriesItemPreviousMetrics` | `AnalyticsMetricValues` | +| `AnalyticsResponsePayloadTimeSeriesItemPreviousRateBases` | `AnalyticsRateBases` | +| `AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesBounceRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesComplaintRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesDeferralRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesEffectiveDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesHumanClickRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemPreviousRateBasesHumanOpenRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemRateBases` | `AnalyticsRateBases` | +| `AnalyticsResponsePayloadTimeSeriesItemRateBasesBounceRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemRateBasesComplaintRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemRateBasesDeferralRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemRateBasesDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemRateBasesEffectiveDeliveryRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemRateBasesHumanClickRate` | `AnalyticsRateBase` | +| `AnalyticsResponsePayloadTimeSeriesItemRateBasesHumanOpenRate` | `AnalyticsRateBase` | +| `BlockedFileTypesResponse` | `BlockedFileTypes` | +| `CancelScheduledMessageResponse` | `ScheduledMessage` | +| `CursorPaginator` | `CursorPage[T]` (generic) | +| `DomainDestroyResponse` | `MessageResponse` | +| `DomainIndexResponse` | `ListDomainsResponse` | +| `DomainShowResponse` | `DomainData` | +| `DomainStoreRequest` | `StoreDomainData` | +| `DomainStoreResponse` | `DomainData` | +| `DomainUpdateProjectsRequest` | `UpdateDomainProjectsData` | +| `DomainUpdateProjectsResponse` | `DomainMutationResponse` | +| `DomainVerifyDnsRecordsResponse` | `DnsVerificationSuccessResponse` | +| `DomainVerifySpecificDnsRecordResponse` | `MessageResponse` | +| `EmailAttachment` | `MessageAttachmentInput` (wire format), or `lettermint.EmailAttachment` (content may be bytes) | +| `EmailPayload` | `SendMailRequest`, or `lettermint.EmailMessage` (attachment content may be bytes) | +| `EmailStatus` | `MessageStatus` | +| `MessageEventsResponse` | `ListMessageEventsResponse` | +| `MessageIndexResponse` | `ListMessagesResponse` | +| `MessageShowResponse` | `MessageData` | +| `ProjectDestroyResponse` | `MessageResponse` | +| `ProjectIndexResponse` | `ListProjectsResponse` | +| `ProjectRotateTokenResponse` | `RotateProjectTokenResponse` | +| `ProjectShowResponse` | `ProjectData` | +| `ProjectStoreRequest` | `StoreProjectData` | +| `ProjectStoreResponse` | `ProjectCreatedData` | +| `ProjectUpdateRequest` | `UpdateProjectData` | +| `ProjectUpdateResponse` | `ProjectMutationResponse` | +| `RescheduleMessageResponse` | `ScheduledMessage` | +| `RouteDestroyResponse` | `MessageResponse` | +| `RouteIndexResponse` | `ListRoutesResponse` | +| `RouteShowResponse` | `RouteData` | +| `RouteStoreRequest` | `StoreRouteData` | +| `RouteStoreResponse` | `RouteMutationResponse` | +| `RouteUpdateRequest` | `UpdateRouteData` | +| `RouteUpdateResponse` | `RouteMutationResponse` | +| `RouteVerifyInboundDomainResponse` | `InboundDomainVerificationResponse` | +| `SendBatchEmailResponse` | `SendBatchMailResponse` | +| `SendEmailResponse` | `SendMailResponse` (`PendingSendMailResponse \| ScheduledSendMailResponse`) | +| `StatsIndexResponse` | `StatsData` | +| `SuppressionDestroyResponse` | `DeleteSuppressionResponse` | +| `SuppressionIndexResponse` | `ListSuppressionsResponse` | +| `SuppressionStoreRequest` | `StoreSuppressionData` | +| `TeamMembersAssignmentUpdateRequest` | `UpdateTeamMemberAssignmentData` | +| `TeamMembersAssignmentUpdateResponse` | `TeamMemberData` | +| `TeamMembersResponse` | `ListTeamMembersResponse` | +| `TeamMembersShowResponse` | `TeamMemberData` | +| `TeamRolesResponse` | `TeamRoleListResponse` | +| `TeamShowResponse` | `TeamData` | +| `TeamUpdateRequest` | `UpdateTeamData` | +| `TeamUpdateResponse` | `TeamMutationResponse` | +| `TeamUsageResponse` | `TeamUsageDetailData` | +| `UpdateReportForwardingRequest` | `ReportForwardingRequest` | +| `WebhookDeliveriesResponse` | `ListWebhookDeliveriesResponse` | +| `WebhookDestroyResponse` | `MessageResponse` | +| `WebhookIndexResponse` | `ListWebhooksResponse` | +| `WebhookRegenerateSecretResponse` | `WebhookSecretResponse` | +| `WebhookShowDeliveryResponse` | `WebhookDeliveryData` | +| `WebhookShowResponse` | `WebhookData` | +| `WebhookStoreRequest` | `StoreWebhookData` | +| `WebhookStoreResponse` | `WebhookSecretResponse` | +| `WebhookTestResponse` | `TestWebhookResponse` | +| `WebhookUpdateRequest` | `UpdateWebhookData` | +| `WebhookUpdateResponse` | `WebhookMutationResponse` | +| `PingResponse` | `str` (`ping()` returns the text) | + +### Removed types + +lettermint#2582 removed these schemas from the API specification: + +| 2.x | 3.0 | +| --- | --- | +| `AnalyticsResponseData` | Removed. Use `AnalyticsResponse` (`data: AnalyticsResults`). | +| `StatsRequestData` | Removed. Use `GetStatsQuery`, the parameters of `stats.retrieve()`. | +| Message list `meta` (`MessageIndexResponseMeta`) | Removed; not exported by 2.x. Lists are flat `CursorPage[T]`. | +| Message events `meta` (`MessageEventsResponseMeta`) | Removed; not exported by 2.x. | +| `SuppressionStoreResponseMessage1` | Removed; not exported by 2.x. `SuppressionStoreResponse["message"]` is a `str`. | + +### Removed classes, modules and helpers + +| 2.x | 3.0 | +| --- | --- | +| `Lettermint.email(token, ...)`, `AsyncLettermint.email(token, ...)` | `Lettermint(sending_token=token, ...).emails` | +| `Lettermint.api(token, ...)`, `AsyncLettermint.api(token, ...)` | `Lettermint(team_token=token, ...)` | +| `Lettermint(api_token=...)`, `client.email` | `Lettermint(sending_token=...)`, `lettermint.emails` | +| `ApiClient`, `AsyncApiClient` | `Lettermint`, `AsyncLettermint` | +| `lettermint.endpoints` (`EmailEndpoint`, `AsyncEmailEndpoint`, `Endpoint`, `AsyncEndpoint`, `DomainsEndpoint`, `MessagesEndpoint`, `ProjectsEndpoint`, `RoutesEndpoint`, `StatsEndpoint`, `SuppressionsEndpoint`, `TeamEndpoint`, `WebhooksEndpoint` and their `Async*` versions) | `Emails`, `EmailBuilder`, `Domains`, `Messages`, `Projects`, `ReportForwarding`, `Routes`, `Stats`, `Suppressions`, `Team`, `TeamMembers`, `Webhooks`, `WebhookDeliveries` and their `Async*` versions, exported for type hints; use the attributes of the client | +| `lettermint.client` (`LettermintClient`, `AsyncLettermintClient`, `get`, `post`, `put`, `patch`, `delete`, `get_raw`) | Removed. Every documented endpoint has a method. Pass `http_client=` to customize HTTP. | +| `lettermint.lettermint` | Removed; import from `lettermint` | +| `lettermint.message_tag` (`MessageTag`, `normalize_message_tags`) | Tags are dicts (`lettermint.types.MessageTagInput`), validated by the SDK | +| `lettermint.exceptions.HttpRequestError`, `ClientError` | `APIError` and its subclasses | +| `lettermint.exceptions.TimeoutError` | `APITimeoutError` | +| `InvalidSignatureError`, `TimestampToleranceError`, `JsonDecodeError` | `WebhookVerificationError` with a `reason` | +| `Webhook.verify_headers(headers, payload)` | `Webhook.verify(raw_body, headers)` | +| `Webhook.verify(payload, signature, timestamp)` | `Webhook.verify_signature(raw_body, signature_header, timestamp)` | +| `Webhook.verify_signature(payload, signature, secret, timestamp, tolerance)` (static) | `Webhook(secret, tolerance).verify_signature(raw_body, signature_header, timestamp)` | +| Top-level `EmailAttachment`, `EmailPayload`, `EmailStatus`, `SendEmailResponse`, `SendBatchEmailResponse` | `lettermint.EmailAttachment` (attachment of an `EmailMessage`), `lettermint.EmailMessage`, `lettermint.types.MessageStatus`, `SendMailResponse`, `SendBatchMailResponse` | + +# Upgrade to v2 This guide covers upgrading from the latest released v1 Python SDK to v2. diff --git a/examples/async_send.py b/examples/async_send.py new file mode 100644 index 0000000..4918de9 --- /dev/null +++ b/examples/async_send.py @@ -0,0 +1,29 @@ +"""Send a batch of emails with the asynchronous client. + +LETTERMINT_PROJECT_TOKEN=lm_... python examples/async_send.py +""" + +import asyncio +import os +from pathlib import Path + +from lettermint import AsyncLettermint + + +async def main() -> None: + async with AsyncLettermint(sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"]) as lettermint: + base = lettermint.emails.compose().from_("billing@acme.com").subject("Your invoice") + invoice = Path(__file__).read_bytes() # any bytes; the SDK encodes them + results = await lettermint.emails.send_batch( + [ + base.to("jane@example.com") + .text("Hi Jane") + .attach("invoice.txt", invoice, content_type="text/plain"), + base.to("john@example.com").text("Hi John"), + ] + ) + for result in results: + print(result["message_id"], result["status"]) + + +asyncio.run(main()) diff --git a/examples/send_email.py b/examples/send_email.py new file mode 100644 index 0000000..b1f72bb --- /dev/null +++ b/examples/send_email.py @@ -0,0 +1,21 @@ +"""Send an email with the synchronous client. + +LETTERMINT_PROJECT_TOKEN=lm_... python examples/send_email.py +""" + +import os + +from lettermint import Lettermint + +with Lettermint(sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"]) as lettermint: + result = ( + lettermint.emails.compose() + .from_("Acme ") + .to("jane@example.com") + .subject("Welcome to Acme") + .html("

Welcome!

") + .text("Welcome!") + .tags([{"name": "campaign", "value": "welcome"}]) + .send(idempotency_key="welcome-jane") + ) + print(result["message_id"], result["status"]) diff --git a/examples/webhook_verification.py b/examples/webhook_verification.py new file mode 100644 index 0000000..403b19f --- /dev/null +++ b/examples/webhook_verification.py @@ -0,0 +1,24 @@ +"""Verify Lettermint webhook deliveries in a Flask app. + +LETTERMINT_WEBHOOK_SECRET=whsec_... flask --app examples/webhook_verification.py run +""" + +import os + +from flask import Flask, request + +from lettermint import Webhook, WebhookVerificationError + +app = Flask(__name__) +webhook = Webhook(os.environ["LETTERMINT_WEBHOOK_SECRET"]) + + +@app.post("/webhooks/lettermint") +def lettermint_webhook() -> tuple[str, int]: + try: + event = webhook.verify(request.get_data(), request.headers) + except WebhookVerificationError as error: + app.logger.warning("Rejected a webhook delivery: %s", error.reason) + return "Invalid signature", 400 + app.logger.info("Received %s", event["event"]) + return "", 204 From fcb76282509def96fa7a510019ad02058e239168 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sun, 4 Oct 2026 00:03:39 +0200 Subject: [PATCH 7/8] docs: list the remaining removed 2.x endpoint classes and constants in UPGRADE.md --- UPGRADE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 69ef94f..cf3d6e5 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -614,8 +614,8 @@ lettermint#2582 removed these schemas from the API specification: | `Lettermint.api(token, ...)`, `AsyncLettermint.api(token, ...)` | `Lettermint(team_token=token, ...)` | | `Lettermint(api_token=...)`, `client.email` | `Lettermint(sending_token=...)`, `lettermint.emails` | | `ApiClient`, `AsyncApiClient` | `Lettermint`, `AsyncLettermint` | -| `lettermint.endpoints` (`EmailEndpoint`, `AsyncEmailEndpoint`, `Endpoint`, `AsyncEndpoint`, `DomainsEndpoint`, `MessagesEndpoint`, `ProjectsEndpoint`, `RoutesEndpoint`, `StatsEndpoint`, `SuppressionsEndpoint`, `TeamEndpoint`, `WebhooksEndpoint` and their `Async*` versions) | `Emails`, `EmailBuilder`, `Domains`, `Messages`, `Projects`, `ReportForwarding`, `Routes`, `Stats`, `Suppressions`, `Team`, `TeamMembers`, `Webhooks`, `WebhookDeliveries` and their `Async*` versions, exported for type hints; use the attributes of the client | -| `lettermint.client` (`LettermintClient`, `AsyncLettermintClient`, `get`, `post`, `put`, `patch`, `delete`, `get_raw`) | Removed. Every documented endpoint has a method. Pass `http_client=` to customize HTTP. | +| `lettermint.endpoints` (`EmailEndpoint`, `AsyncEmailEndpoint`, `Endpoint`, `AsyncEndpoint`, `DomainsEndpoint`, `MessagesEndpoint`, `ProjectsEndpoint`, `RoutesEndpoint`, `StatsEndpoint`, `SuppressionsEndpoint`, `TeamEndpoint`, `WebhooksEndpoint`, and `AsyncDomainsEndpoint`, `AsyncMessagesEndpoint`, `AsyncProjectsEndpoint`, `AsyncRoutesEndpoint`, `AsyncStatsEndpoint`, `AsyncSuppressionsEndpoint`, `AsyncTeamEndpoint`, `AsyncWebhooksEndpoint`) | `Emails`, `EmailBuilder`, `Domains`, `Messages`, `Projects`, `ReportForwarding`, `Routes`, `Stats`, `Suppressions`, `Team`, `TeamMembers`, `Webhooks`, `WebhookDeliveries` and their `Async*` versions, exported for type hints; use the attributes of the client | +| `lettermint.client` (`LettermintClient`, `AsyncLettermintClient` with `get`, `post`, `put`, `patch`, `delete`, `get_raw`; `DEFAULT_BASE_URL`, `DEFAULT_TIMEOUT`) | Removed. Every documented endpoint has a method. Pass `http_client=` to customize HTTP. | | `lettermint.lettermint` | Removed; import from `lettermint` | | `lettermint.message_tag` (`MessageTag`, `normalize_message_tags`) | Tags are dicts (`lettermint.types.MessageTagInput`), validated by the SDK | | `lettermint.exceptions.HttpRequestError`, `ClientError` | `APIError` and its subclasses | From 9222817532ae8fa4b35916e4d52de097b8ad6b21 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sun, 4 Oct 2026 00:48:41 +0200 Subject: [PATCH 8/8] chore: regenerate types from the merged API spec The spec at lettermint/lettermint@0b4ecbdd23 (main, the #2582 merge) is identical to the previous pin; only the generated file headers change. --- src/lettermint/_generated/__init__.py | 2 +- src/lettermint/_generated/operations.py | 2 +- src/lettermint/_generated/types.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lettermint/_generated/__init__.py b/src/lettermint/_generated/__init__.py index 9d72d5f..b603cdf 100644 --- a/src/lettermint/_generated/__init__.py +++ b/src/lettermint/_generated/__init__.py @@ -1,5 +1,5 @@ # Generated by lettermint/sdk-generator — do not edit. -# Spec: lettermint/lettermint@80d8ab2a2d73644c2bdee8a4bb9d3565aa017d72 (feat/openapi-named-schemas) +# Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main) # sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f # team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd # Naming profile: next diff --git a/src/lettermint/_generated/operations.py b/src/lettermint/_generated/operations.py index 206a1c3..88785a1 100644 --- a/src/lettermint/_generated/operations.py +++ b/src/lettermint/_generated/operations.py @@ -1,5 +1,5 @@ # Generated by lettermint/sdk-generator — do not edit. -# Spec: lettermint/lettermint@80d8ab2a2d73644c2bdee8a4bb9d3565aa017d72 (feat/openapi-named-schemas) +# Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main) # sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f # team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd # Naming profile: next diff --git a/src/lettermint/_generated/types.py b/src/lettermint/_generated/types.py index 86d442e..766dd4f 100644 --- a/src/lettermint/_generated/types.py +++ b/src/lettermint/_generated/types.py @@ -1,5 +1,5 @@ # Generated by lettermint/sdk-generator — do not edit. -# Spec: lettermint/lettermint@80d8ab2a2d73644c2bdee8a4bb9d3565aa017d72 (feat/openapi-named-schemas) +# Spec: lettermint/lettermint@0b4ecbdd237d155305b9aab19efe96b2a4830446 (main) # sending-openapi.json SHA-256: 53e80a6c21cd8995a02c75dfbef79773242cd0d60a5e6262eb79dc127f779c7f # team-openapi.json SHA-256: 8b379814a0f501d2c564858bc382a29133535e704cc279545a934db611d587dd # Naming profile: next