diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e85440d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + name: Lint & Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Ruff check + run: ruff check src/lettr tests + + - name: Ruff format check + run: ruff format --check src/lettr tests + + - name: Mypy + run: mypy src/lettr + + test: + name: Test (Python ${{ matrix.python-version }}) + needs: lint + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run tests + run: pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6509c79..646c2fe 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,28 +27,9 @@ jobs: name: dist path: dist/ - publish-testpypi: - name: Publish to TestPyPI - needs: build - runs-on: ubuntu-latest - environment: testpypi - permissions: - id-token: write - steps: - - name: Download build artifacts - uses: actions/download-artifact@v4 - with: - name: dist - path: dist/ - - - name: Publish to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - publish-pypi: name: Publish to PyPI - needs: [build, publish-testpypi] + needs: build runs-on: ubuntu-latest environment: pypi permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..37ac4fc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,101 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed +- `emails.list()`, `emails.get()`, and `emails.list_events()` were reading + responses at the wrong nesting level and silently returned empty results + when the API actually had data. Now correctly read from + `data.events.data` (list endpoints) and `data.events` (detail endpoint), + matching the OpenAPI spec. +- `emails.schedule()` crashed with `KeyError: 'transmission_id'` because + it parsed the wrong response shape. Per the spec, the endpoint returns + the same envelope as `POST /emails` (`request_id`, `accepted`, + `rejected`), not a full transmission detail. +- `emails.get_scheduled()` was using the wrong `ScheduledEmail` shape + (missing required `recipients`/`events`; `events` was typed as + `list[str]` instead of `list[EmailEvent]`). +- `Lettr.auth_check()` crashed with `KeyError: 'team_name'` — the spec + returns `team_id` + `timestamp`, not `team_name`. +- `Lettr.health()` now also surfaces the `timestamp` field returned by + the API. +- `domains.create()` (and any future endpoint returning a DKIM object) + no longer crashes when the API returns unknown DKIM fields. All nested + dataclass parsers (`DkimInfo`, `DmarcValidationResult`, + `SpfValidationResult`, `GeoIp`, `UserAgentParsed`) now silently ignore + unknown keys instead of raising `TypeError`. +- Added `signing_domain` field to `DkimInfo` (was missing from the SDK + but present in the spec). + +### Changed (breaking) +- `EmailDetail` dataclass reshaped to match the spec. Was + `{ results: list[EmailEvent], total_count: int }`; now has + `transmission_id`, `state`, `from_email`, `subject`, `recipients`, + `num_recipients`, `events`, plus optional `scheduled_at` and `from_name`. + Code that did `detail.results` should use `detail.events`. +- `EmailList` and `EmailEventList` gained `date_from` / `date_to` fields + exposing the query window returned by the API. +- `ScheduledEmail` dataclass reshaped to match the spec (same shape as + `EmailDetail`): required fields are now `transmission_id`, `state`, + `from_email`, `subject`, `recipients`, `num_recipients`, `events`; + removed the spurious `created_at`; `events` is now + `list[EmailEvent]` (was `list[str]`). +- `emails.schedule()` now returns `SendEmailResponse` (not + `ScheduledEmail`). Use `emails.get_scheduled(request_id)` to retrieve + the full transmission detail afterwards. +- `AuthCheck` dataclass: `team_name` removed, `timestamp` added (matches + the spec). +- `HealthCheck` dataclass: added required `timestamp` field. + +## [0.2.0] + +### Added +- `client.health()` — check API health (no auth required) +- `client.auth_check()` — validate API key and get team info +- `client.emails.list_events()` — list email events with filtering +- `client.emails.schedule()` — schedule email for future delivery +- `client.emails.get_scheduled()` / `cancel_scheduled()` — manage scheduled emails +- `client.webhooks.create()` / `update()` / `delete()` — full webhook CRUD +- `client.templates.get_html()` — retrieve rendered template HTML +- `tag` and `headers` parameters to `emails.send()` +- `from_date` / `to_date` query params to `emails.get()` +- `ForbiddenError` (403) and `RateLimitError` (429) exceptions +- New types: `HealthCheck`, `AuthCheck`, `GeoIp`, `UserAgentParsed`, + `EmailEventList`, `ScheduledEmail`, `DmarcValidationResult`, + `SpfValidationResult` +- `dmarc_status`, `spf_status`, `is_primary_domain`, `dns_provider` fields + on `Domain`; `dmarc` / `spf` validation objects on `DomainVerification` +- `perform_substitutions` option on `EmailOptions` +- Additional event fields (`campaign_id`, `template_id`, geo/user-agent, etc.) + on `EmailEvent` +- Comprehensive test suite (72 tests) + +### Changed +- `emails.send()` — `subject` is now optional (required only when not using + a template) +- `ScheduledEmail.id` → `transmission_id` to match spec +- Bumped minimum SDK version to 0.2.0 + +### Removed +- `campaign_id` kwarg from `emails.send()` and `emails.schedule()` — not + part of the spec's send request schema + +## [0.1.0] + +### Added +- Initial release +- `Lettr` client with resource-based API (`emails`, `domains`, `templates`, + `webhooks`, `projects`) +- Bearer token authentication, context manager support +- Typed exception hierarchy (`LettrError`, `AuthenticationError`, + `ValidationError`, `NotFoundError`, `ConflictError`, `BadRequestError`, + `ServerError`) + +[Unreleased]: https://github.com/lettr/lettr-python/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/lettr/lettr-python/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/lettr/lettr-python/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 1adf97c..742a887 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ client.emails.send( ## Usage +### Health & Authentication + +```python +# Check API health (no authentication required) +health = client.health() +print(health.status) # "ok" + +# Validate your API key +auth = client.auth_check() +print(f"Team: {auth.team_name} (ID: {auth.team_id})") +``` + ### Sending Emails ```python @@ -58,6 +70,8 @@ response = client.emails.send( subject="Your Order Confirmation", html="

Order Confirmed

", text="Order Confirmed", + tag="order-confirmation", + headers={"X-Order-Id": "12345"}, options=lettr.EmailOptions( click_tracking=True, open_tracking=True, @@ -96,7 +110,6 @@ response = client.emails.send( response = client.emails.send( from_email="hello@example.com", to=["user@example.com"], - subject="Welcome, {{first_name}}!", template_slug="welcome-email", substitution_data={ "first_name": "John", @@ -105,6 +118,27 @@ response = client.emails.send( ) ``` +### Scheduling Emails + +```python +# Schedule an email for future delivery +response = client.emails.schedule( + from_email="sender@example.com", + to=["recipient@example.com"], + subject="Scheduled Message", + html="

This was scheduled!

", + scheduled_at="2025-12-01T10:00:00Z", +) +print(f"Scheduled: {response.request_id}") + +# Get scheduled email details +detail = client.emails.get_scheduled(response.request_id) +print(f"{detail.subject} ({detail.state}) -> {detail.recipients}") + +# Cancel a scheduled email +client.emails.cancel_scheduled("transmission_id") +``` + ### Listing & Retrieving Emails ```python @@ -124,10 +158,31 @@ filtered = client.emails.list(from_date="2026-01-01", to_date="2026-01-31") # Get email details (all events for a transmission) detail = client.emails.get("request_id_here") -for event in detail.results: +print(f"{detail.subject} -> {detail.recipients} ({detail.state})") +for event in detail.events: print(f"{event.type}: {event.timestamp}") ``` +### Email Events + +```python +# List email events with filtering +events = client.emails.list_events( + events=["delivery", "bounce"], + recipients=["user@example.com"], + from_date="2025-01-01", + to_date="2025-12-31", + per_page=50, +) + +for event in events.results: + print(f"{event.type}: {event.rcpt_to} at {event.timestamp}") + if event.geo_ip: + print(f" Location: {event.geo_ip.city}, {event.geo_ip.country}") + if event.user_agent_parsed: + print(f" Client: {event.user_agent_parsed.agent_family}") +``` + ### Domains ```python @@ -140,13 +195,16 @@ for domain in domains: domain = client.domains.create("example.com") print(domain.dkim) # DKIM config for DNS setup -# Get domain details +# Get domain details (includes DMARC, SPF, DNS provider info) domain = client.domains.get("example.com") +print(f"DMARC: {domain.dmarc_status}, SPF: {domain.spf_status}") # Verify DNS records verification = client.domains.verify("example.com") print(f"DKIM: {verification.dkim_status}") print(f"CNAME: {verification.cname_status}") +print(f"DMARC: {verification.dmarc_status}") +print(f"SPF: {verification.spf_status}") # Delete a domain client.domains.delete("example.com") @@ -170,6 +228,9 @@ template = client.templates.create( template = client.templates.get("welcome-email") print(template.html) +# Get template HTML +html = client.templates.get_html(project_id=1, slug="welcome-email") + # Update a template (creates a new version) template = client.templates.update( "welcome-email", @@ -193,8 +254,37 @@ webhooks = client.webhooks.list() for webhook in webhooks: print(f"{webhook.name}: {webhook.url} (enabled: {webhook.enabled})") +# Create a webhook +webhook = client.webhooks.create( + name="My Webhook", + url="https://example.com/webhook", + auth_type="none", + events_mode="selected", + events=["delivery", "bounce", "spam_complaint"], +) + +# Create with authentication +webhook = client.webhooks.create( + name="Secure Webhook", + url="https://example.com/webhook", + auth_type="basic", + events_mode="all", + auth_username="user", + auth_password="secret", +) + +# Update a webhook +webhook = client.webhooks.update( + "webhook-abc123", + name="Renamed Webhook", + active=False, +) + # Get webhook details webhook = client.webhooks.get("webhook-abc123") + +# Delete a webhook +client.webhooks.delete("webhook-abc123") ``` ### Projects @@ -227,8 +317,12 @@ except lettr.ValidationError as e: print(f"Field errors: {e.errors}") except lettr.AuthenticationError: print("Invalid API key") +except lettr.ForbiddenError: + print("Access forbidden") except lettr.NotFoundError as e: print(f"Not found: {e.message}") +except lettr.RateLimitError as e: + print(f"Rate limited: {e.message} (code: {e.error_code})") except lettr.BadRequestError as e: print(f"Bad request: {e.message} (code: {e.error_code})") except lettr.ServerError as e: @@ -241,12 +335,14 @@ except lettr.LettrError as e: | Exception | HTTP Status | Description | |---|---|---| -| `LettrError` | — | Base exception for all errors | +| `LettrError` | -- | Base exception for all errors | | `AuthenticationError` | 401 | Missing or invalid API key | +| `ForbiddenError` | 403 | Access forbidden | | `ValidationError` | 422 | Request validation failed | | `NotFoundError` | 404 | Resource not found | | `ConflictError` | 409 | Resource already exists | | `BadRequestError` | 400 | Invalid domain or request | +| `RateLimitError` | 429 | Quota or rate limit exceeded | | `ServerError` | 500, 502 | Server-side error | ## Context Manager diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..54a38d2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,92 @@ +# Releasing + +This project uses [Semantic Versioning](https://semver.org/). Releases are +published to PyPI automatically by GitHub Actions when a GitHub Release is +published. + +## Versioning policy + +- **MAJOR** (`1.0.0` → `2.0.0`): breaking API changes +- **MINOR** (`0.2.0` → `0.3.0`): new features, backwards-compatible +- **PATCH** (`0.2.0` → `0.2.1`): bug fixes only + +Pre-1.0 minor bumps may contain breaking changes (noted in the changelog). + +## Version is stored in three places + +Keep all three in sync: + +1. `pyproject.toml` → `version = "X.Y.Z"` +2. `src/lettr/__init__.py` → `__version__ = "X.Y.Z"` +3. `src/lettr/_client.py` → `User-Agent` header (two occurrences: + `request()` and `get_no_auth()`) + +## Release checklist + +1. **Update the changelog.** Move items from `[Unreleased]` into a new + `[X.Y.Z]` section in `CHANGELOG.md`. Add a new empty `[Unreleased]` + section at the top. Update the compare links at the bottom. + +2. **Bump the version** in the three files above. + +3. **Run the checks locally:** + ```bash + source .venv/bin/activate + python -m pytest tests/ -v + ruff check src/ tests/ + ``` + +4. **Commit and push on `main`:** + ```bash + git add CHANGELOG.md pyproject.toml src/lettr/ + git commit -m "Release vX.Y.Z" + git push origin main + ``` + +5. **Tag the release:** + ```bash + git tag vX.Y.Z + git push origin vX.Y.Z + ``` + +6. **Create a GitHub Release** from the tag. The body should mirror the + changelog entry for this version. + ```bash + gh release create vX.Y.Z --title "vX.Y.Z" --notes-from-tag + ``` + (or use the GitHub web UI) + +7. **Watch the workflow.** The `.github/workflows/publish.yml` workflow + fires on release publish: + - Builds the sdist and wheel + - Publishes to TestPyPI + - Publishes to PyPI + + Both PyPI targets use [trusted publishing](https://docs.pypi.org/trusted-publishers/) + via OIDC, so no API tokens are needed — but the `testpypi` and `pypi` + environments must be configured in the GitHub repo settings with + matching trusted publisher entries on PyPI. + +8. **Verify** the new version appears at + and installs cleanly: + ```bash + pip install --upgrade lettr + python -c "import lettr; print(lettr.__version__)" + ``` + +## What if the workflow fails? + +- **TestPyPI step fails:** TestPyPI rejects re-uploads of an existing + version. Delete the release, bump to the next patch, and retry. +- **PyPI step fails:** same constraint — a published version cannot be + overwritten. You must bump to the next version. +- **Never yank/delete a PyPI release** unless it has a critical issue; + prefer publishing a fix release instead. + +## Cutting a v1.0.0 + +When the API surface is considered stable: + +1. Ensure the changelog's `[Unreleased]` section is clean. +2. Add a `## [1.0.0]` section documenting the stability commitment. +3. Follow the release checklist above with `X.Y.Z = 1.0.0`. diff --git a/pyproject.toml b/pyproject.toml index b43dd9b..7c0544e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lettr" -version = "0.1.0" +version = "0.2.0" description = "Official Python SDK for the Lettr Email API" readme = "README.md" license = "MIT" @@ -32,6 +32,13 @@ dependencies = [ "httpx>=0.24.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "ruff>=0.4.0", + "mypy>=1.10.0", +] + [project.urls] Homepage = "https://lettr.com" Documentation = "https://lettr.com/docs" diff --git a/src/lettr/__init__.py b/src/lettr/__init__.py index f2507fb..64926ee 100644 --- a/src/lettr/__init__.py +++ b/src/lettr/__init__.py @@ -16,39 +16,52 @@ from __future__ import annotations -from typing import Optional - -from ._client import ApiClient, DEFAULT_BASE_URL, DEFAULT_TIMEOUT +from ._client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, ApiClient from ._exceptions import ( AuthenticationError, BadRequestError, ConflictError, + ForbiddenError, LettrError, NotFoundError, + RateLimitError, ServerError, ValidationError, ) from ._types import ( Attachment, + AuthCheck, + DkimInfo, + DmarcValidationResult, + DnsProvider, Domain, + DomainDnsVerification, DomainVerification, Email, EmailDetail, EmailEvent, + EmailEventList, EmailList, EmailOptions, + GeoIp, + HealthCheck, MergeTag, + MergeTagChild, Project, ProjectList, + ScheduledEmail, SendEmailResponse, + SpfValidationResult, Template, + TemplateHtml, TemplateList, TemplateMergeTags, + UserAgentParsed, Webhook, ) from .resources import Domains, Emails, Projects, Templates, Webhooks -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ # Client @@ -58,25 +71,40 @@ "AuthenticationError", "BadRequestError", "ConflictError", + "ForbiddenError", "NotFoundError", + "RateLimitError", "ServerError", "ValidationError", # Types "Attachment", + "AuthCheck", + "DkimInfo", + "DmarcValidationResult", + "DnsProvider", "Domain", + "DomainDnsVerification", "DomainVerification", "Email", "EmailDetail", "EmailEvent", + "EmailEventList", "EmailList", "EmailOptions", + "GeoIp", + "HealthCheck", "MergeTag", + "MergeTagChild", "Project", "ProjectList", + "ScheduledEmail", "SendEmailResponse", + "SpfValidationResult", "Template", + "TemplateHtml", "TemplateList", "TemplateMergeTags", + "UserAgentParsed", "Webhook", ] @@ -124,8 +152,7 @@ def __init__( ) -> None: if not api_key: raise ValueError( - "The api_key parameter is required. " - "Get your API key at https://app.lettr.com" + "The api_key parameter is required. Get your API key at https://app.lettr.com" ) self._client = ApiClient(api_key=api_key, base_url=base_url, timeout=timeout) @@ -145,11 +172,37 @@ def __init__( self.projects = Projects(self._client) """Project management operations.""" + def health(self) -> HealthCheck: + """Check API health. No authentication required. + + Returns: + A :class:`HealthCheck` with the API status. + """ + body = self._client.get_no_auth("/health") + data = body["data"] + return HealthCheck(status=data["status"], timestamp=data["timestamp"]) + + def auth_check(self) -> AuthCheck: + """Validate the API key and return team information. + + Returns: + An :class:`AuthCheck` with team details. + + Raises: + AuthenticationError: If the API key is invalid. + """ + body = self._client.get("/auth/check") + data = body["data"] + return AuthCheck( + team_id=data["team_id"], + timestamp=data["timestamp"], + ) + def close(self) -> None: """Close the underlying HTTP connection pool.""" self._client.close() - def __enter__(self) -> "Lettr": + def __enter__(self) -> Lettr: return self def __exit__(self, *args: object) -> None: diff --git a/src/lettr/__pycache__/__init__.cpython-313.pyc b/src/lettr/__pycache__/__init__.cpython-313.pyc index 1a6673a..88da3f6 100644 Binary files a/src/lettr/__pycache__/__init__.cpython-313.pyc and b/src/lettr/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/lettr/__pycache__/_client.cpython-313.pyc b/src/lettr/__pycache__/_client.cpython-313.pyc index c762c15..78226e2 100644 Binary files a/src/lettr/__pycache__/_client.cpython-313.pyc and b/src/lettr/__pycache__/_client.cpython-313.pyc differ diff --git a/src/lettr/__pycache__/_exceptions.cpython-313.pyc b/src/lettr/__pycache__/_exceptions.cpython-313.pyc index 56cb550..46c67a9 100644 Binary files a/src/lettr/__pycache__/_exceptions.cpython-313.pyc and b/src/lettr/__pycache__/_exceptions.cpython-313.pyc differ diff --git a/src/lettr/__pycache__/_types.cpython-313.pyc b/src/lettr/__pycache__/_types.cpython-313.pyc index 401d4e0..453d9d2 100644 Binary files a/src/lettr/__pycache__/_types.cpython-313.pyc and b/src/lettr/__pycache__/_types.cpython-313.pyc differ diff --git a/src/lettr/_client.py b/src/lettr/_client.py index 6fb861c..4839a64 100644 --- a/src/lettr/_client.py +++ b/src/lettr/_client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any import httpx @@ -23,6 +23,7 @@ def __init__( ) -> None: self._api_key = api_key self._base_url = base_url.rstrip("/") + self._timeout = timeout self._http = httpx.Client( base_url=self._base_url, timeout=timeout, @@ -30,7 +31,7 @@ def __init__( "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "lettr-python/0.1.0", + "User-Agent": "lettr-python/0.2.0", }, ) @@ -41,8 +42,8 @@ def request( method: str, path: str, *, - json: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, ) -> Any: """Send an HTTP request and return the decoded JSON body. @@ -70,23 +71,53 @@ def request( raise_for_status(response.status_code, body) return body - def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any: + def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: return self.request("GET", path, params=params) - def post(self, path: str, *, json: Optional[Dict[str, Any]] = None) -> Any: + def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any: return self.request("POST", path, json=json) - def put(self, path: str, *, json: Optional[Dict[str, Any]] = None) -> Any: + def put(self, path: str, *, json: dict[str, Any] | None = None) -> Any: return self.request("PUT", path, json=json) - def delete(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any: + def delete(self, path: str, *, params: dict[str, Any] | None = None) -> Any: return self.request("DELETE", path, params=params) + def get_no_auth(self, path: str, *, params: dict[str, Any] | None = None) -> Any: + """Send a GET request without the Authorization header. + + Used for endpoints that don't require authentication (e.g. health check). + """ + if params: + params = {k: v for k, v in params.items() if v is not None} + + try: + response = httpx.get( + f"{self._base_url}{path}", + params=params, + timeout=self._timeout, + headers={ + "Accept": "application/json", + "User-Agent": "lettr-python/0.2.0", + }, + ) + except httpx.HTTPError as exc: + raise LettrError(f"HTTP request failed: {exc}") from exc + + try: + body = response.json() + except Exception: + raise_for_status(response.status_code, None) + return None + + raise_for_status(response.status_code, body) + return body + def close(self) -> None: """Close the underlying HTTP connection pool.""" self._http.close() - def __enter__(self) -> "ApiClient": + def __enter__(self) -> ApiClient: return self def __exit__(self, *args: Any) -> None: diff --git a/src/lettr/_exceptions.py b/src/lettr/_exceptions.py index 0659c3f..588ba27 100644 --- a/src/lettr/_exceptions.py +++ b/src/lettr/_exceptions.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any class LettrError(Exception): @@ -17,13 +17,21 @@ class AuthenticationError(LettrError): """Raised when the API key is missing or invalid (401).""" +class ForbiddenError(LettrError): + """Raised when access is forbidden (403).""" + + def __init__(self, message: str, error_code: str | None = None) -> None: + self.error_code = error_code + super().__init__(message) + + class ValidationError(LettrError): """Raised when request validation fails (422).""" def __init__( self, message: str, - errors: Optional[Dict[str, List[str]]] = None, + errors: dict[str, list[str]] | None = None, ) -> None: self.errors = errors or {} super().__init__(message) @@ -40,15 +48,31 @@ def __str__(self) -> str: class NotFoundError(LettrError): """Raised when a resource is not found (404).""" + def __init__(self, message: str, error_code: str | None = None) -> None: + self.error_code = error_code + super().__init__(message) + class ConflictError(LettrError): """Raised when a resource already exists (409).""" + def __init__(self, message: str, error_code: str | None = None) -> None: + self.error_code = error_code + super().__init__(message) + class BadRequestError(LettrError): """Raised for client-side errors (400).""" - def __init__(self, message: str, error_code: Optional[str] = None) -> None: + def __init__(self, message: str, error_code: str | None = None) -> None: + self.error_code = error_code + super().__init__(message) + + +class RateLimitError(LettrError): + """Raised when rate limit or quota is exceeded (429).""" + + def __init__(self, message: str, error_code: str | None = None) -> None: self.error_code = error_code super().__init__(message) @@ -56,7 +80,7 @@ def __init__(self, message: str, error_code: Optional[str] = None) -> None: class ServerError(LettrError): """Raised for server-side errors (500, 502).""" - def __init__(self, message: str, error_code: Optional[str] = None) -> None: + def __init__(self, message: str, error_code: str | None = None) -> None: self.error_code = error_code super().__init__(message) @@ -72,20 +96,26 @@ def raise_for_status(status_code: int, body: Any) -> None: message = body.get("message", "Unknown error") error_code = body.get("error_code") + if status_code == 400: + raise BadRequestError(message=message, error_code=error_code) + if status_code == 401: raise AuthenticationError(message) + if status_code == 403: + raise ForbiddenError(message=message, error_code=error_code) + if status_code == 404: - raise NotFoundError(message) + raise NotFoundError(message=message, error_code=error_code) if status_code == 409: - raise ConflictError(message) + raise ConflictError(message=message, error_code=error_code) if status_code == 422: raise ValidationError(message=message, errors=body.get("errors")) - if status_code == 400: - raise BadRequestError(message=message, error_code=error_code) + if status_code == 429: + raise RateLimitError(message=message, error_code=error_code) if status_code >= 500: raise ServerError(message=message, error_code=error_code) diff --git a/src/lettr/_types.py b/src/lettr/_types.py index 22a2067..d3a4ad2 100644 --- a/src/lettr/_types.py +++ b/src/lettr/_types.py @@ -2,8 +2,69 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +from dataclasses import dataclass, fields +from typing import Any, TypeVar + +T = TypeVar("T") + + +def _from_dict(cls: type[T], data: dict[str, Any]) -> T: + """Build a dataclass instance from a dict, ignoring unknown keys. + + Server payloads may add new fields over time; using bare ``cls(**data)`` + crashes on the first unknown key. This helper filters ``data`` to only + the fields declared on ``cls`` so that forward-compatible parsing works. + """ + known = {f.name for f in fields(cls)} # type: ignore[arg-type] + return cls(**{k: v for k, v in data.items() if k in known}) + + +# --------------------------------------------------------------------------- +# Common / shared types +# --------------------------------------------------------------------------- + + +@dataclass +class HealthCheck: + """Response from the health check endpoint.""" + + status: str + timestamp: str + + +@dataclass +class AuthCheck: + """Response from the auth check endpoint.""" + + team_id: int + timestamp: str + + +@dataclass +class GeoIp: + """Geolocation data associated with an email event.""" + + country: str | None = None + region: str | None = None + city: str | None = None + latitude: float | None = None + longitude: float | None = None + zip: str | None = None + postal_code: str | None = None + + +@dataclass +class UserAgentParsed: + """Parsed user-agent data associated with an email event.""" + + agent_family: str | None = None + device_brand: str | None = None + device_family: str | None = None + os_family: str | None = None + os_version: str | None = None + is_mobile: bool | None = None + is_proxy: bool | None = None + is_prefetched: bool | None = None # --------------------------------------------------------------------------- @@ -29,13 +90,13 @@ class Attachment: class EmailOptions: """Delivery options for an email.""" - click_tracking: Optional[bool] = None - open_tracking: Optional[bool] = None - transactional: Optional[bool] = None - inline_css: Optional[bool] = None - perform_substitutions: Optional[bool] = None + click_tracking: bool | None = None + open_tracking: bool | None = None + transactional: bool | None = None + inline_css: bool | None = None + perform_substitutions: bool | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {k: v for k, v in self.__dict__.items() if v is not None} @@ -52,53 +113,119 @@ class SendEmailResponse: class Email: """A sent email event.""" - event_id: Optional[str] = None - timestamp: Optional[str] = None - request_id: Optional[str] = None - message_id: Optional[str] = None - subject: Optional[str] = None - friendly_from: Optional[str] = None - sending_domain: Optional[str] = None - rcpt_to: Optional[str] = None - raw_rcpt_to: Optional[str] = None - recipient_domain: Optional[str] = None - mailbox_provider: Optional[str] = None - mailbox_provider_region: Optional[str] = None - sending_ip: Optional[str] = None - click_tracking: Optional[bool] = None - open_tracking: Optional[bool] = None - transactional: Optional[bool] = None - msg_size: Optional[int] = None - injection_time: Optional[str] = None - rcpt_meta: Optional[Dict[str, Any]] = None + event_id: str | None = None + type: str | None = None + timestamp: str | None = None + request_id: str | None = None + message_id: str | None = None + subject: str | None = None + friendly_from: str | None = None + sending_domain: str | None = None + rcpt_to: str | None = None + raw_rcpt_to: str | None = None + recipient_domain: str | None = None + mailbox_provider: str | None = None + mailbox_provider_region: str | None = None + sending_ip: str | None = None + click_tracking: bool | None = None + open_tracking: bool | None = None + transactional: bool | None = None + msg_size: int | None = None + injection_time: str | None = None + rcpt_meta: dict[str, Any] | None = None @dataclass class EmailEvent(Email): """A detailed email event including type and error info.""" - type: Optional[str] = None - reason: Optional[str] = None - raw_reason: Optional[str] = None - error_code: Optional[str] = None + reason: str | None = None + raw_reason: str | None = None + error_code: str | None = None + bounce_class: int | None = None + queue_time: int | None = None + outbound_tls: str | None = None + num_retries: int | None = None + device_token: str | None = None + target_link_url: str | None = None + target_link_name: str | None = None + user_agent: str | None = None + ip_address: str | None = None + initial_pixel: bool | None = None + fbtype: str | None = None + report_by: str | None = None + report_to: str | None = None + remote_addr: str | None = None + campaign_id: str | None = None + template_id: str | None = None + template_version: str | None = None + ip_pool: str | None = None + msg_from: str | None = None + rcpt_type: str | None = None + rcpt_tags: list[str] | None = None + amp_enabled: bool | None = None + delv_method: str | None = None + recv_method: str | None = None + routing_domain: str | None = None + scheduled_time: str | None = None + ab_test_id: str | None = None + ab_test_version: str | None = None + geo_ip: GeoIp | None = None + user_agent_parsed: UserAgentParsed | None = None @dataclass class EmailList: """Paginated list of sent emails.""" - results: List[Email] + results: list[Email] total_count: int - next_cursor: Optional[str] = None + next_cursor: str | None = None per_page: int = 25 + date_from: str | None = None + date_to: str | None = None @dataclass class EmailDetail: - """Detailed email with all events.""" + """Detailed email with its delivery events.""" + + transmission_id: str + state: str + from_email: str + subject: str + recipients: list[str] + num_recipients: int + events: list[EmailEvent] + scheduled_at: str | None = None + from_name: str | None = None + - results: List[EmailEvent] +@dataclass +class EmailEventList: + """Paginated list of email events.""" + + results: list[EmailEvent] total_count: int + next_cursor: str | None = None + per_page: int = 25 + date_from: str | None = None + date_to: str | None = None + + +@dataclass +class ScheduledEmail: + """A scheduled email transmission (same shape as :class:`EmailDetail`).""" + + transmission_id: str + state: str + from_email: str + subject: str + recipients: list[str] + num_recipients: int + events: list[EmailEvent] + scheduled_at: str | None = None + from_name: str | None = None # --------------------------------------------------------------------------- @@ -110,9 +237,45 @@ class EmailDetail: class DkimInfo: """DKIM configuration for a domain.""" - public: Optional[str] = None - selector: Optional[str] = None - headers: Optional[str] = None + public: str | None = None + selector: str | None = None + headers: str | None = None + signing_domain: str | None = None + + +@dataclass +class DmarcValidationResult: + """DMARC DNS validation result.""" + + is_valid: bool | None = None + status: str | None = None + found_at_domain: str | None = None + record: str | None = None + policy: str | None = None + subdomain_policy: str | None = None + error: str | None = None + covered_by_parent_policy: bool | None = None + + +@dataclass +class SpfValidationResult: + """SPF DNS validation result.""" + + is_valid: bool | None = None + status: str | None = None + record: str | None = None + error: str | None = None + includes_sparkpost: bool | None = None + + +@dataclass +class DnsProvider: + """DNS provider information for a domain.""" + + provider: str | None = None + provider_label: str | None = None + nameservers: list[str] | None = None + error: str | None = None @dataclass @@ -122,14 +285,32 @@ class Domain: domain: str status: str status_label: str - can_send: Optional[bool] = None - cname_status: Optional[str] = None - dkim_status: Optional[str] = None - tracking_domain: Optional[str] = None - dns: Optional[Dict[str, Any]] = None - dkim: Optional[DkimInfo] = None - created_at: Optional[str] = None - updated_at: Optional[str] = None + can_send: bool | None = None + cname_status: str | None = None + dkim_status: str | None = None + dmarc_status: str | None = None + spf_status: str | None = None + is_primary_domain: bool | None = None + tracking_domain: str | None = None + dns: dict[str, Any] | None = None + dns_provider: DnsProvider | None = None + dkim: DkimInfo | None = None + created_at: str | None = None + updated_at: str | None = None + + +@dataclass +class DomainDnsVerification: + """DNS verification error details for a domain.""" + + dkim_record: str | None = None + cname_record: str | None = None + dkim_error: str | None = None + cname_error: str | None = None + dmarc_record: str | None = None + dmarc_error: str | None = None + spf_record: str | None = None + spf_error: str | None = None @dataclass @@ -139,8 +320,13 @@ class DomainVerification: domain: str dkim_status: str cname_status: str - ownership_verified: Optional[str] = None - dns: Optional[Dict[str, Any]] = None + dmarc_status: str + spf_status: str + is_primary_domain: bool + ownership_verified: str | None = None + dns: DomainDnsVerification | None = None + dmarc: DmarcValidationResult | None = None + spf: SpfValidationResult | None = None # --------------------------------------------------------------------------- @@ -158,10 +344,10 @@ class Webhook: enabled: bool auth_type: str has_auth_credentials: bool - event_types: Optional[List[str]] = None - last_successful_at: Optional[str] = None - last_failure_at: Optional[str] = None - last_status: Optional[str] = None + event_types: list[str] | None = None + last_successful_at: str | None = None + last_failure_at: str | None = None + last_status: str | None = None # --------------------------------------------------------------------------- @@ -174,7 +360,7 @@ class MergeTagChild: """A child merge tag within a loop block.""" key: str - type: Optional[str] = None + type: str | None = None @dataclass @@ -183,8 +369,9 @@ class MergeTag: key: str required: bool = False - type: Optional[str] = None - children: Optional[List[MergeTagChild]] = None + type: str | None = None + name: str | None = None + children: list[MergeTagChild] | None = None @dataclass @@ -197,19 +384,19 @@ class Template: project_id: int folder_id: int created_at: str - updated_at: Optional[str] = None - active_version: Optional[int] = None - versions_count: Optional[int] = None - html: Optional[str] = None - json: Optional[str] = None - merge_tags: Optional[List[MergeTag]] = None + updated_at: str | None = None + active_version: int | None = None + versions_count: int | None = None + html: str | None = None + json: str | None = None + merge_tags: list[MergeTag] | None = None @dataclass class TemplateList: """Paginated list of templates.""" - templates: List[Template] + templates: list[Template] total: int per_page: int current_page: int @@ -222,7 +409,17 @@ class TemplateMergeTags: template_slug: str version: int - merge_tags: List[MergeTag] + merge_tags: list[MergeTag] + project_id: int | None = None + + +@dataclass +class TemplateHtml: + """Response from the get template HTML endpoint.""" + + html: str + merge_tags: list[MergeTag] | None = None + subject: str | None = None # --------------------------------------------------------------------------- @@ -238,15 +435,15 @@ class Project: name: str team_id: int created_at: str - emoji: Optional[str] = None - updated_at: Optional[str] = None + emoji: str | None = None + updated_at: str | None = None @dataclass class ProjectList: """Paginated list of projects.""" - projects: List[Project] + projects: list[Project] total: int per_page: int current_page: int diff --git a/src/lettr/resources/__pycache__/__init__.cpython-313.pyc b/src/lettr/resources/__pycache__/__init__.cpython-313.pyc index 8ba3db9..9dc38ec 100644 Binary files a/src/lettr/resources/__pycache__/__init__.cpython-313.pyc and b/src/lettr/resources/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/lettr/resources/__pycache__/domains.cpython-313.pyc b/src/lettr/resources/__pycache__/domains.cpython-313.pyc index ba1dd46..9c03391 100644 Binary files a/src/lettr/resources/__pycache__/domains.cpython-313.pyc and b/src/lettr/resources/__pycache__/domains.cpython-313.pyc differ diff --git a/src/lettr/resources/__pycache__/emails.cpython-313.pyc b/src/lettr/resources/__pycache__/emails.cpython-313.pyc index 1bf6a26..c6c2eb8 100644 Binary files a/src/lettr/resources/__pycache__/emails.cpython-313.pyc and b/src/lettr/resources/__pycache__/emails.cpython-313.pyc differ diff --git a/src/lettr/resources/__pycache__/projects.cpython-313.pyc b/src/lettr/resources/__pycache__/projects.cpython-313.pyc index c827e04..da79895 100644 Binary files a/src/lettr/resources/__pycache__/projects.cpython-313.pyc and b/src/lettr/resources/__pycache__/projects.cpython-313.pyc differ diff --git a/src/lettr/resources/__pycache__/templates.cpython-313.pyc b/src/lettr/resources/__pycache__/templates.cpython-313.pyc index 1d07d90..b35f63e 100644 Binary files a/src/lettr/resources/__pycache__/templates.cpython-313.pyc and b/src/lettr/resources/__pycache__/templates.cpython-313.pyc differ diff --git a/src/lettr/resources/__pycache__/webhooks.cpython-313.pyc b/src/lettr/resources/__pycache__/webhooks.cpython-313.pyc index 1d58cdd..5242d06 100644 Binary files a/src/lettr/resources/__pycache__/webhooks.cpython-313.pyc and b/src/lettr/resources/__pycache__/webhooks.cpython-313.pyc differ diff --git a/src/lettr/resources/domains.py b/src/lettr/resources/domains.py index fbff04b..1c66f8d 100644 --- a/src/lettr/resources/domains.py +++ b/src/lettr/resources/domains.py @@ -2,10 +2,19 @@ from __future__ import annotations -from typing import List +import builtins from .._client import ApiClient -from .._types import DkimInfo, Domain, DomainVerification +from .._types import ( + DkimInfo, + DmarcValidationResult, + DnsProvider, + Domain, + DomainDnsVerification, + DomainVerification, + SpfValidationResult, + _from_dict, +) class Domains: @@ -21,7 +30,7 @@ class Domains: def __init__(self, client: ApiClient) -> None: self._client = client - def list(self) -> List[Domain]: + def list(self) -> builtins.list[Domain]: """List all sending domains. Returns: @@ -33,11 +42,11 @@ def list(self) -> List[Domain]: domain=d["domain"], status=d["status"], status_label=d["status_label"], - can_send=d.get("can_send"), + can_send=d["can_send"], cname_status=d.get("cname_status"), dkim_status=d.get("dkim_status"), - created_at=d.get("created_at"), - updated_at=d.get("updated_at"), + created_at=d["created_at"], + updated_at=d["updated_at"], ) for d in body["data"]["domains"] ] @@ -56,6 +65,9 @@ def get(self, domain: str) -> Domain: """ body = self._client.get(f"/domains/{domain}") d = body["data"] + dns_provider = None + if d.get("dns_provider"): + dns_provider = _from_dict(DnsProvider, d["dns_provider"]) return Domain( domain=d["domain"], status=d["status"], @@ -63,8 +75,12 @@ def get(self, domain: str) -> Domain: can_send=d.get("can_send"), cname_status=d.get("cname_status"), dkim_status=d.get("dkim_status"), + dmarc_status=d.get("dmarc_status"), + spf_status=d.get("spf_status"), + is_primary_domain=d.get("is_primary_domain"), tracking_domain=d.get("tracking_domain"), dns=d.get("dns"), + dns_provider=dns_provider, created_at=d.get("created_at"), updated_at=d.get("updated_at"), ) @@ -89,7 +105,7 @@ def create(self, domain: str) -> Domain: d = body["data"] dkim = None if d.get("dkim"): - dkim = DkimInfo(**d["dkim"]) + dkim = _from_dict(DkimInfo, d["dkim"]) return Domain( domain=d["domain"], status=d["status"], @@ -109,7 +125,7 @@ def delete(self, domain: str) -> None: self._client.delete(f"/domains/{domain}") def verify(self, domain: str) -> DomainVerification: - """Verify a domain's DNS records (DKIM and CNAME). + """Verify a domain's DNS records (DKIM, CNAME, SPF, DMARC). Args: domain: The domain name to verify. @@ -122,10 +138,28 @@ def verify(self, domain: str) -> DomainVerification: """ body = self._client.post(f"/domains/{domain}/verify") d = body["data"] + + dmarc = None + if d.get("dmarc"): + dmarc = _from_dict(DmarcValidationResult, d["dmarc"]) + + spf = None + if d.get("spf"): + spf = _from_dict(SpfValidationResult, d["spf"]) + + dns = None + if d.get("dns"): + dns = _from_dict(DomainDnsVerification, d["dns"]) + return DomainVerification( domain=d["domain"], dkim_status=d["dkim_status"], cname_status=d["cname_status"], + dmarc_status=d["dmarc_status"], + spf_status=d["spf_status"], + is_primary_domain=d["is_primary_domain"], ownership_verified=d.get("ownership_verified"), - dns=d.get("dns"), + dns=dns, + dmarc=dmarc, + spf=spf, ) diff --git a/src/lettr/resources/emails.py b/src/lettr/resources/emails.py index 41167bb..11ce946 100644 --- a/src/lettr/resources/emails.py +++ b/src/lettr/resources/emails.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Sequence from .._client import ApiClient from .._types import ( @@ -10,12 +10,195 @@ Email, EmailDetail, EmailEvent, + EmailEventList, EmailList, EmailOptions, + GeoIp, + ScheduledEmail, SendEmailResponse, + UserAgentParsed, + _from_dict, ) +def _parse_email(r: dict[str, Any]) -> Email: + """Parse a raw dict into an Email.""" + return Email( + event_id=r.get("event_id"), + type=r.get("type"), + timestamp=r.get("timestamp"), + request_id=r.get("request_id"), + message_id=r.get("message_id"), + subject=r.get("subject"), + friendly_from=r.get("friendly_from"), + sending_domain=r.get("sending_domain"), + rcpt_to=r.get("rcpt_to"), + raw_rcpt_to=r.get("raw_rcpt_to"), + recipient_domain=r.get("recipient_domain"), + mailbox_provider=r.get("mailbox_provider"), + mailbox_provider_region=r.get("mailbox_provider_region"), + sending_ip=r.get("sending_ip"), + click_tracking=r.get("click_tracking"), + open_tracking=r.get("open_tracking"), + transactional=r.get("transactional"), + msg_size=r.get("msg_size"), + injection_time=r.get("injection_time"), + rcpt_meta=r.get("rcpt_meta"), + ) + + +def _parse_email_event(r: dict[str, Any]) -> EmailEvent: + """Parse a raw dict into an EmailEvent, handling nested objects.""" + geo_ip = None + if r.get("geo_ip"): + geo_ip = _from_dict(GeoIp, r["geo_ip"]) + user_agent_parsed = None + if r.get("user_agent_parsed"): + user_agent_parsed = _from_dict(UserAgentParsed, r["user_agent_parsed"]) + + return EmailEvent( + event_id=r.get("event_id"), + type=r.get("type"), + timestamp=r.get("timestamp"), + request_id=r.get("request_id"), + message_id=r.get("message_id"), + subject=r.get("subject"), + friendly_from=r.get("friendly_from"), + sending_domain=r.get("sending_domain"), + rcpt_to=r.get("rcpt_to"), + raw_rcpt_to=r.get("raw_rcpt_to"), + recipient_domain=r.get("recipient_domain"), + mailbox_provider=r.get("mailbox_provider"), + mailbox_provider_region=r.get("mailbox_provider_region"), + sending_ip=r.get("sending_ip"), + click_tracking=r.get("click_tracking"), + open_tracking=r.get("open_tracking"), + transactional=r.get("transactional"), + msg_size=r.get("msg_size"), + injection_time=r.get("injection_time"), + rcpt_meta=r.get("rcpt_meta"), + reason=r.get("reason"), + raw_reason=r.get("raw_reason"), + error_code=r.get("error_code"), + bounce_class=r.get("bounce_class"), + queue_time=r.get("queue_time"), + outbound_tls=r.get("outbound_tls"), + num_retries=r.get("num_retries"), + device_token=r.get("device_token"), + target_link_url=r.get("target_link_url"), + target_link_name=r.get("target_link_name"), + user_agent=r.get("user_agent"), + ip_address=r.get("ip_address"), + initial_pixel=r.get("initial_pixel"), + fbtype=r.get("fbtype"), + report_by=r.get("report_by"), + report_to=r.get("report_to"), + remote_addr=r.get("remote_addr"), + campaign_id=r.get("campaign_id"), + template_id=r.get("template_id"), + template_version=r.get("template_version"), + ip_pool=r.get("ip_pool"), + msg_from=r.get("msg_from"), + rcpt_type=r.get("rcpt_type"), + rcpt_tags=r.get("rcpt_tags"), + amp_enabled=r.get("amp_enabled"), + delv_method=r.get("delv_method"), + recv_method=r.get("recv_method"), + routing_domain=r.get("routing_domain"), + scheduled_time=r.get("scheduled_time"), + ab_test_id=r.get("ab_test_id"), + ab_test_version=r.get("ab_test_version"), + geo_ip=geo_ip, + user_agent_parsed=user_agent_parsed, + ) + + +def _parse_scheduled_email(data: dict[str, Any]) -> ScheduledEmail: + """Parse a raw dict into a ScheduledEmail.""" + return ScheduledEmail( + transmission_id=data["transmission_id"], + state=data["state"], + from_email=data["from"], + subject=data["subject"], + recipients=data["recipients"], + num_recipients=data["num_recipients"], + events=[_parse_email_event(e) for e in data["events"]], + scheduled_at=data.get("scheduled_at"), + from_name=data.get("from_name"), + ) + + +def _build_email_payload( + *, + from_email: str, + to: Sequence[str], + subject: str | None = None, + html: str | None = None, + text: str | None = None, + from_name: str | None = None, + cc: Sequence[str] | None = None, + bcc: Sequence[str] | None = None, + reply_to: str | None = None, + reply_to_name: str | None = None, + amp_html: str | None = None, + template_slug: str | None = None, + template_version: int | None = None, + project_id: int | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + substitution_data: dict[str, Any] | None = None, + options: EmailOptions | None = None, + attachments: Sequence[Attachment] | None = None, +) -> dict[str, Any]: + """Build the JSON payload for sending or scheduling an email.""" + payload: dict[str, Any] = { + "from": from_email, + "to": list(to), + } + + if subject is not None: + payload["subject"] = subject + if html is not None: + payload["html"] = html + if text is not None: + payload["text"] = text + if from_name is not None: + payload["from_name"] = from_name + if cc is not None: + payload["cc"] = list(cc) + if bcc is not None: + payload["bcc"] = list(bcc) + if reply_to is not None: + payload["reply_to"] = reply_to + if reply_to_name is not None: + payload["reply_to_name"] = reply_to_name + if amp_html is not None: + payload["amp_html"] = amp_html + if template_slug is not None: + payload["template_slug"] = template_slug + if template_version is not None: + payload["template_version"] = template_version + if project_id is not None: + payload["project_id"] = project_id + if tag is not None: + payload["tag"] = tag + if metadata is not None: + payload["metadata"] = metadata + if headers is not None: + payload["headers"] = headers + if substitution_data is not None: + payload["substitution_data"] = substitution_data + if options is not None: + payload["options"] = options.to_dict() + if attachments is not None: + payload["attachments"] = [ + {"name": a.name, "type": a.type, "data": a.data} for a in attachments + ] + + return payload + + class Emails: """Operations for sending and retrieving emails. @@ -37,23 +220,24 @@ def send( *, from_email: str, to: Sequence[str], - subject: str, - html: Optional[str] = None, - text: Optional[str] = None, - from_name: Optional[str] = None, - cc: Optional[Sequence[str]] = None, - bcc: Optional[Sequence[str]] = None, - reply_to: Optional[str] = None, - reply_to_name: Optional[str] = None, - amp_html: Optional[str] = None, - template_slug: Optional[str] = None, - template_version: Optional[int] = None, - project_id: Optional[int] = None, - campaign_id: Optional[str] = None, - metadata: Optional[Dict[str, str]] = None, - substitution_data: Optional[Dict[str, Any]] = None, - options: Optional[EmailOptions] = None, - attachments: Optional[Sequence[Attachment]] = None, + subject: str | None = None, + html: str | None = None, + text: str | None = None, + from_name: str | None = None, + cc: Sequence[str] | None = None, + bcc: Sequence[str] | None = None, + reply_to: str | None = None, + reply_to_name: str | None = None, + amp_html: str | None = None, + template_slug: str | None = None, + template_version: int | None = None, + project_id: int | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + substitution_data: dict[str, Any] | None = None, + options: EmailOptions | None = None, + attachments: Sequence[Attachment] | None = None, ) -> SendEmailResponse: """Send a transactional email. @@ -63,7 +247,7 @@ def send( Args: from_email: Sender email address. to: List of recipient email addresses. - subject: Email subject line. + subject: Email subject line. Required unless using a template. html: HTML content of the email. text: Plain text content of the email. from_name: Sender display name. @@ -75,8 +259,9 @@ def send( template_slug: Template slug to use for content. template_version: Specific template version number. project_id: Project ID containing the template. - campaign_id: Campaign identifier for tracking. + tag: Tracking tag (max 64 characters). metadata: Custom metadata for tracking. + headers: Custom email headers (max 10). substitution_data: Variables for template substitution. options: Delivery options (tracking, etc.). attachments: File attachments. @@ -89,47 +274,30 @@ def send( ValidationError: If required fields are missing or invalid. BadRequestError: If the sender domain is invalid or unconfigured. NotFoundError: If the template or project is not found. + RateLimitError: If sending quota is exceeded. """ - payload: Dict[str, Any] = { - "from": from_email, - "to": list(to), - "subject": subject, - } - - if html is not None: - payload["html"] = html - if text is not None: - payload["text"] = text - if from_name is not None: - payload["from_name"] = from_name - if cc is not None: - payload["cc"] = list(cc) - if bcc is not None: - payload["bcc"] = list(bcc) - if reply_to is not None: - payload["reply_to"] = reply_to - if reply_to_name is not None: - payload["reply_to_name"] = reply_to_name - if amp_html is not None: - payload["amp_html"] = amp_html - if template_slug is not None: - payload["template_slug"] = template_slug - if template_version is not None: - payload["template_version"] = template_version - if project_id is not None: - payload["project_id"] = project_id - if campaign_id is not None: - payload["campaign_id"] = campaign_id - if metadata is not None: - payload["metadata"] = metadata - if substitution_data is not None: - payload["substitution_data"] = substitution_data - if options is not None: - payload["options"] = options.to_dict() - if attachments is not None: - payload["attachments"] = [ - {"name": a.name, "type": a.type, "data": a.data} for a in attachments - ] + payload = _build_email_payload( + from_email=from_email, + to=to, + subject=subject, + html=html, + text=text, + from_name=from_name, + cc=cc, + bcc=bcc, + reply_to=reply_to, + reply_to_name=reply_to_name, + amp_html=amp_html, + template_slug=template_slug, + template_version=template_version, + project_id=project_id, + tag=tag, + metadata=metadata, + headers=headers, + substitution_data=substitution_data, + options=options, + attachments=attachments, + ) body = self._client.post("/emails", json=payload) data = body["data"] @@ -142,11 +310,11 @@ def send( def list( self, *, - per_page: Optional[int] = None, - cursor: Optional[str] = None, - recipients: Optional[str] = None, - from_date: Optional[str] = None, - to_date: Optional[str] = None, + per_page: int | None = None, + cursor: str | None = None, + recipients: str | None = None, + from_date: str | None = None, + to_date: str | None = None, ) -> EmailList: """List sent emails with cursor-based pagination. @@ -160,7 +328,7 @@ def list( Returns: An :class:`EmailList` with results and pagination info. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if per_page is not None: params["per_page"] = per_page if cursor is not None: @@ -173,23 +341,33 @@ def list( params["to"] = to_date body = self._client.get("/emails", params=params) - data = body["data"] + events = body["data"]["events"] - results = [Email(**r) for r in data["results"]] - pagination = data.get("pagination", {}) + results = [_parse_email(r) for r in events["data"]] + pagination = events.get("pagination", {}) return EmailList( results=results, - total_count=data["total_count"], + total_count=events["total_count"], next_cursor=pagination.get("next_cursor"), per_page=pagination.get("per_page", 25), + date_from=events.get("from"), + date_to=events.get("to"), ) - def get(self, request_id: str) -> EmailDetail: + def get( + self, + request_id: str, + *, + from_date: str | None = None, + to_date: str | None = None, + ) -> EmailDetail: """Get all events for a specific email transmission. Args: request_id: The ``request_id`` returned when the email was sent. + from_date: Start date for event search range (ISO 8601). + to_date: End date for event search range (ISO 8601). Returns: An :class:`EmailDetail` with all associated events. @@ -197,7 +375,204 @@ def get(self, request_id: str) -> EmailDetail: Raises: NotFoundError: If the email is not found. """ - body = self._client.get(f"/emails/{request_id}") + params: dict[str, Any] = {} + if from_date is not None: + params["from"] = from_date + if to_date is not None: + params["to"] = to_date + + body = self._client.get(f"/emails/{request_id}", params=params) + data = body["data"] + events = [_parse_email_event(r) for r in data["events"]] + return EmailDetail( + transmission_id=data["transmission_id"], + state=data["state"], + from_email=data["from"], + subject=data["subject"], + recipients=data["recipients"], + num_recipients=data["num_recipients"], + events=events, + scheduled_at=data.get("scheduled_at"), + from_name=data.get("from_name"), + ) + + def list_events( + self, + *, + events: Sequence[str] | None = None, + recipients: Sequence[str] | None = None, + from_date: str | None = None, + to_date: str | None = None, + per_page: int | None = None, + cursor: str | None = None, + transmissions: str | None = None, + bounce_classes: str | None = None, + ) -> EmailEventList: + """List email events with filtering. + + Args: + events: Event types to filter by (e.g. ``["delivery", "bounce"]``). + recipients: Recipient email addresses to filter by. + from_date: Start date (ISO 8601). Defaults to 10 days ago. + to_date: End date (ISO 8601). Defaults to now. + per_page: Number of events per page. + cursor: Pagination cursor from a previous response. + transmissions: Filter by transmission ID. + bounce_classes: Comma-separated bounce classification codes. + + Returns: + An :class:`EmailEventList` with events and pagination info. + """ + params: dict[str, Any] = {} + if events is not None: + params["events"] = ",".join(events) + if recipients is not None: + params["recipients"] = ",".join(recipients) + if from_date is not None: + params["from"] = from_date + if to_date is not None: + params["to"] = to_date + if per_page is not None: + params["per_page"] = per_page + if cursor is not None: + params["cursor"] = cursor + if transmissions is not None: + params["transmissions"] = transmissions + if bounce_classes is not None: + params["bounce_classes"] = bounce_classes + + body = self._client.get("/emails/events", params=params) + events_data = body["data"]["events"] + + results = [_parse_email_event(r) for r in events_data["data"]] + pagination = events_data.get("pagination", {}) + + return EmailEventList( + results=results, + total_count=events_data["total_count"], + next_cursor=pagination.get("next_cursor"), + per_page=pagination.get("per_page", 25), + date_from=events_data.get("from"), + date_to=events_data.get("to"), + ) + + def schedule( + self, + *, + from_email: str, + to: Sequence[str], + scheduled_at: str, + subject: str | None = None, + html: str | None = None, + text: str | None = None, + from_name: str | None = None, + cc: Sequence[str] | None = None, + bcc: Sequence[str] | None = None, + reply_to: str | None = None, + reply_to_name: str | None = None, + amp_html: str | None = None, + template_slug: str | None = None, + template_version: int | None = None, + project_id: int | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + substitution_data: dict[str, Any] | None = None, + options: EmailOptions | None = None, + attachments: Sequence[Attachment] | None = None, + ) -> SendEmailResponse: + """Schedule a transactional email for future delivery. + + Args: + from_email: Sender email address. + to: List of recipient email addresses. + scheduled_at: Scheduled delivery time (ISO 8601). + subject: Email subject line. Required unless using a template. + html: HTML content of the email. + text: Plain text content of the email. + from_name: Sender display name. + cc: Carbon copy recipients. + bcc: Blind carbon copy recipients. + reply_to: Reply-To email address. + reply_to_name: Reply-To display name. + amp_html: AMP HTML content. + template_slug: Template slug to use for content. + template_version: Specific template version number. + project_id: Project ID containing the template. + tag: Tracking tag (max 64 characters). + metadata: Custom metadata for tracking. + headers: Custom email headers (max 10). + substitution_data: Variables for template substitution. + options: Delivery options (tracking, etc.). + attachments: File attachments. + + Returns: + A :class:`SendEmailResponse` with ``request_id``, ``accepted``, + and ``rejected`` counts. Use :meth:`get_scheduled` with the + ``request_id`` to retrieve the full scheduled transmission. + + Raises: + ValidationError: If required fields are missing or invalid. + ForbiddenError: If scheduling is not permitted. + RateLimitError: If sending quota is exceeded. + """ + payload = _build_email_payload( + from_email=from_email, + to=to, + subject=subject, + html=html, + text=text, + from_name=from_name, + cc=cc, + bcc=bcc, + reply_to=reply_to, + reply_to_name=reply_to_name, + amp_html=amp_html, + template_slug=template_slug, + template_version=template_version, + project_id=project_id, + tag=tag, + metadata=metadata, + headers=headers, + substitution_data=substitution_data, + options=options, + attachments=attachments, + ) + payload["scheduled_at"] = scheduled_at + + body = self._client.post("/emails/scheduled", json=payload) data = body["data"] - results = [EmailEvent(**r) for r in data["results"]] - return EmailDetail(results=results, total_count=data["total_count"]) + return SendEmailResponse( + request_id=data["request_id"], + accepted=data["accepted"], + rejected=data["rejected"], + ) + + def get_scheduled(self, transmission_id: str) -> ScheduledEmail: + """Get details of a scheduled email transmission. + + Args: + transmission_id: The transmission ID. + + Returns: + A :class:`ScheduledEmail` with the transmission details. + + Raises: + NotFoundError: If the transmission is not found. + ForbiddenError: If access is not permitted. + """ + body = self._client.get(f"/emails/scheduled/{transmission_id}") + return _parse_scheduled_email(body["data"]) + + def cancel_scheduled(self, transmission_id: str) -> None: + """Cancel a scheduled email transmission before it is sent. + + Args: + transmission_id: The transmission ID to cancel. + + Raises: + NotFoundError: If the transmission is not found. + ConflictError: If the transmission has already been sent. + ForbiddenError: If cancellation is not permitted. + """ + self._client.delete(f"/emails/scheduled/{transmission_id}") diff --git a/src/lettr/resources/projects.py b/src/lettr/resources/projects.py index 5f67146..8d6eb2b 100644 --- a/src/lettr/resources/projects.py +++ b/src/lettr/resources/projects.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any from .._client import ApiClient from .._types import Project, ProjectList @@ -22,8 +22,8 @@ def __init__(self, client: ApiClient) -> None: def list( self, *, - per_page: Optional[int] = None, - page: Optional[int] = None, + per_page: int | None = None, + page: int | None = None, ) -> ProjectList: """List projects with pagination. @@ -34,7 +34,7 @@ def list( Returns: A :class:`ProjectList` with projects and pagination info. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if per_page is not None: params["per_page"] = per_page if page is not None: @@ -51,7 +51,7 @@ def list( team_id=p["team_id"], emoji=p.get("emoji"), created_at=p["created_at"], - updated_at=p.get("updated_at"), + updated_at=p["updated_at"], ) for p in data["projects"] ] diff --git a/src/lettr/resources/templates.py b/src/lettr/resources/templates.py index 9271deb..2aa6c72 100644 --- a/src/lettr/resources/templates.py +++ b/src/lettr/resources/templates.py @@ -2,20 +2,21 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any from .._client import ApiClient from .._types import ( MergeTag, MergeTagChild, Template, + TemplateHtml, TemplateList, TemplateMergeTags, ) -def _parse_merge_tags(raw: List[Dict[str, Any]]) -> List[MergeTag]: - tags: List[MergeTag] = [] +def _parse_merge_tags(raw: list[dict[str, Any]]) -> list[MergeTag]: + tags: list[MergeTag] = [] for t in raw: children = None if t.get("children"): @@ -25,6 +26,7 @@ def _parse_merge_tags(raw: List[Dict[str, Any]]) -> List[MergeTag]: key=t["key"], required=t.get("required", False), type=t.get("type"), + name=t.get("name"), children=children, ) ) @@ -49,9 +51,9 @@ def __init__(self, client: ApiClient) -> None: def list( self, *, - project_id: Optional[int] = None, - per_page: Optional[int] = None, - page: Optional[int] = None, + project_id: int | None = None, + per_page: int | None = None, + page: int | None = None, ) -> TemplateList: """List email templates with pagination. @@ -64,7 +66,7 @@ def list( Returns: A :class:`TemplateList` with templates and pagination info. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if project_id is not None: params["project_id"] = project_id if per_page is not None: @@ -84,7 +86,7 @@ def list( project_id=t["project_id"], folder_id=t["folder_id"], created_at=t["created_at"], - updated_at=t.get("updated_at"), + updated_at=t["updated_at"], ) for t in data["templates"] ] @@ -97,7 +99,7 @@ def list( last_page=pagination["last_page"], ) - def get(self, slug: str, *, project_id: Optional[int] = None) -> Template: + def get(self, slug: str, *, project_id: int | None = None) -> Template: """Get a single template by slug. Args: @@ -110,7 +112,7 @@ def get(self, slug: str, *, project_id: Optional[int] = None) -> Template: Raises: NotFoundError: If the template or project is not found. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if project_id is not None: params["project_id"] = project_id @@ -125,7 +127,7 @@ def get(self, slug: str, *, project_id: Optional[int] = None) -> Template: created_at=d["created_at"], updated_at=d.get("updated_at"), active_version=d.get("active_version"), - versions_count=d.get("versions_count"), + versions_count=d["versions_count"], html=d.get("html"), json=d.get("json"), ) @@ -134,10 +136,10 @@ def create( self, *, name: str, - html: Optional[str] = None, - json: Optional[str] = None, - project_id: Optional[int] = None, - folder_id: Optional[int] = None, + html: str | None = None, + json: str | None = None, + project_id: int | None = None, + folder_id: int | None = None, ) -> Template: """Create a new email template. @@ -158,7 +160,7 @@ def create( ValidationError: If validation fails. NotFoundError: If the project or folder is not found. """ - payload: Dict[str, Any] = {"name": name} + payload: dict[str, Any] = {"name": name} if html is not None: payload["html"] = html if json is not None: @@ -190,10 +192,10 @@ def update( self, slug: str, *, - name: Optional[str] = None, - html: Optional[str] = None, - json: Optional[str] = None, - project_id: Optional[int] = None, + name: str | None = None, + html: str | None = None, + json: str | None = None, + project_id: int | None = None, ) -> Template: """Update an existing email template. @@ -214,7 +216,7 @@ def update( NotFoundError: If the template or project is not found. ValidationError: If validation fails. """ - payload: Dict[str, Any] = {} + payload: dict[str, Any] = {} if name is not None: payload["name"] = name if html is not None: @@ -243,7 +245,7 @@ def update( updated_at=d.get("updated_at"), ) - def delete(self, slug: str, *, project_id: Optional[int] = None) -> None: + def delete(self, slug: str, *, project_id: int | None = None) -> None: """Permanently delete a template and all its versions. Args: @@ -253,7 +255,7 @@ def delete(self, slug: str, *, project_id: Optional[int] = None) -> None: Raises: NotFoundError: If the template or project is not found. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if project_id is not None: params["project_id"] = project_id self._client.delete(f"/templates/{slug}", params=params) @@ -262,8 +264,8 @@ def get_merge_tags( self, slug: str, *, - project_id: Optional[int] = None, - version: Optional[int] = None, + project_id: int | None = None, + version: int | None = None, ) -> TemplateMergeTags: """Get merge tags for a template version. @@ -278,7 +280,7 @@ def get_merge_tags( Raises: NotFoundError: If the template or project is not found. """ - params: Dict[str, Any] = {} + params: dict[str, Any] = {} if project_id is not None: params["project_id"] = project_id if version is not None: @@ -290,4 +292,32 @@ def get_merge_tags( template_slug=d["template_slug"], version=d["version"], merge_tags=_parse_merge_tags(d["merge_tags"]), + project_id=d.get("project_id"), + ) + + def get_html(self, *, project_id: int, slug: str) -> TemplateHtml: + """Get the rendered HTML and merge tags of a template. + + Args: + project_id: Project ID containing the template. + slug: The template slug. + + Returns: + A :class:`TemplateHtml` with ``html``, ``merge_tags``, and + ``subject``. + + Raises: + NotFoundError: If the template or project is not found. + """ + params: dict[str, Any] = { + "project_id": project_id, + "slug": slug, + } + body = self._client.get("/templates/html", params=params) + d = body["data"] + merge_tags = _parse_merge_tags(d.get("merge_tags", [])) + return TemplateHtml( + html=d["html"], + merge_tags=merge_tags, + subject=d.get("subject"), ) diff --git a/src/lettr/resources/webhooks.py b/src/lettr/resources/webhooks.py index a0d0393..b038ca5 100644 --- a/src/lettr/resources/webhooks.py +++ b/src/lettr/resources/webhooks.py @@ -2,46 +2,55 @@ from __future__ import annotations -from typing import List +import builtins +from typing import Any from .._client import ApiClient from .._types import Webhook +def _parse_webhook(w: dict[str, Any]) -> Webhook: + """Parse a raw dict into a Webhook.""" + return Webhook( + id=w["id"], + name=w["name"], + url=w["url"], + enabled=w["enabled"], + auth_type=w["auth_type"], + has_auth_credentials=w["has_auth_credentials"], + event_types=w.get("event_types"), + last_successful_at=w.get("last_successful_at"), + last_failure_at=w.get("last_failure_at"), + last_status=w.get("last_status"), + ) + + class Webhooks: - """Operations for retrieving webhooks. + """Operations for managing webhooks. Usage:: webhooks = client.webhooks.list() webhook = client.webhooks.get("webhook-abc123") + webhook = client.webhooks.create( + name="My Webhook", + url="https://example.com/webhook", + auth_type="none", + events_mode="all", + ) """ def __init__(self, client: ApiClient) -> None: self._client = client - def list(self) -> List[Webhook]: + def list(self) -> builtins.list[Webhook]: """List all webhooks. Returns: A list of :class:`Webhook` objects. """ body = self._client.get("/webhooks") - return [ - Webhook( - id=w["id"], - name=w["name"], - url=w["url"], - enabled=w["enabled"], - auth_type=w["auth_type"], - has_auth_credentials=w["has_auth_credentials"], - event_types=w.get("event_types"), - last_successful_at=w.get("last_successful_at"), - last_failure_at=w.get("last_failure_at"), - last_status=w.get("last_status"), - ) - for w in body["data"]["webhooks"] - ] + return [_parse_webhook(w) for w in body["data"]["webhooks"]] def get(self, webhook_id: str) -> Webhook: """Get details of a single webhook. @@ -56,16 +65,136 @@ def get(self, webhook_id: str) -> Webhook: NotFoundError: If the webhook is not found. """ body = self._client.get(f"/webhooks/{webhook_id}") - w = body["data"] - return Webhook( - id=w["id"], - name=w["name"], - url=w["url"], - enabled=w["enabled"], - auth_type=w["auth_type"], - has_auth_credentials=w["has_auth_credentials"], - event_types=w.get("event_types"), - last_successful_at=w.get("last_successful_at"), - last_failure_at=w.get("last_failure_at"), - last_status=w.get("last_status"), - ) + return _parse_webhook(body["data"]) + + def create( + self, + *, + name: str, + url: str, + auth_type: str, + events_mode: str, + auth_username: str | None = None, + auth_password: str | None = None, + oauth_client_id: str | None = None, + oauth_client_secret: str | None = None, + oauth_token_url: str | None = None, + events: builtins.list[str] | None = None, + ) -> Webhook: + """Create a new webhook. + + Args: + name: Webhook name. + url: Webhook URL to receive events. + auth_type: Authentication type (``"none"``, ``"basic"``, ``"oauth2"``). + events_mode: Event mode (``"all"`` or ``"selected"``). + auth_username: Basic auth username (when ``auth_type="basic"``). + auth_password: Basic auth password (when ``auth_type="basic"``). + oauth_client_id: OAuth2 client ID (when ``auth_type="oauth2"``). + oauth_client_secret: OAuth2 client secret (when ``auth_type="oauth2"``). + oauth_token_url: OAuth2 token URL (when ``auth_type="oauth2"``). + events: Event types to receive (when ``events_mode="selected"``). + + Returns: + A :class:`Webhook` with the created webhook details. + + Raises: + ValidationError: If validation fails. + ConflictError: If a webhook with the same URL already exists. + """ + payload: dict[str, Any] = { + "name": name, + "url": url, + "auth_type": auth_type, + "events_mode": events_mode, + } + if auth_username is not None: + payload["auth_username"] = auth_username + if auth_password is not None: + payload["auth_password"] = auth_password + if oauth_client_id is not None: + payload["oauth_client_id"] = oauth_client_id + if oauth_client_secret is not None: + payload["oauth_client_secret"] = oauth_client_secret + if oauth_token_url is not None: + payload["oauth_token_url"] = oauth_token_url + if events is not None: + payload["events"] = events + + body = self._client.post("/webhooks", json=payload) + return _parse_webhook(body["data"]) + + def update( + self, + webhook_id: str, + *, + name: str | None = None, + target: str | None = None, + auth_type: str | None = None, + auth_username: str | None = None, + auth_password: str | None = None, + oauth_token_url: str | None = None, + oauth_client_id: str | None = None, + oauth_client_secret: str | None = None, + events: builtins.list[str] | None = None, + active: bool | None = None, + ) -> Webhook: + """Update an existing webhook. + + Only provided fields will be updated. + + Args: + webhook_id: The webhook ID to update. + name: New webhook name. + target: New webhook URL. + auth_type: New authentication type. + auth_username: New basic auth username. + auth_password: New basic auth password. + oauth_token_url: New OAuth2 token URL. + oauth_client_id: New OAuth2 client ID. + oauth_client_secret: New OAuth2 client secret. + events: New event types to receive. + active: Enable or disable the webhook. + + Returns: + A :class:`Webhook` with the updated webhook details. + + Raises: + NotFoundError: If the webhook is not found. + ValidationError: If validation fails. + """ + payload: dict[str, Any] = {} + if name is not None: + payload["name"] = name + if target is not None: + payload["target"] = target + if auth_type is not None: + payload["auth_type"] = auth_type + if auth_username is not None: + payload["auth_username"] = auth_username + if auth_password is not None: + payload["auth_password"] = auth_password + if oauth_token_url is not None: + payload["oauth_token_url"] = oauth_token_url + if oauth_client_id is not None: + payload["oauth_client_id"] = oauth_client_id + if oauth_client_secret is not None: + payload["oauth_client_secret"] = oauth_client_secret + if events is not None: + payload["events"] = events + if active is not None: + payload["active"] = active + + body = self._client.put(f"/webhooks/{webhook_id}", json=payload) + return _parse_webhook(body["data"]) + + def delete(self, webhook_id: str) -> None: + """Delete a webhook. + + Args: + webhook_id: The webhook ID to delete. + + Raises: + NotFoundError: If the webhook is not found. + """ + self._client.delete(f"/webhooks/{webhook_id}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..01b8422 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..86cf205 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_client.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_client.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..0b3eef4 Binary files /dev/null and b/tests/__pycache__/test_client.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_domains.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_domains.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..485042e Binary files /dev/null and b/tests/__pycache__/test_domains.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_emails.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_emails.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..5938851 Binary files /dev/null and b/tests/__pycache__/test_emails.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_exceptions.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_exceptions.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..2067c15 Binary files /dev/null and b/tests/__pycache__/test_exceptions.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_lettr.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_lettr.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..6bd69bc Binary files /dev/null and b/tests/__pycache__/test_lettr.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_projects.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_projects.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..b575ae3 Binary files /dev/null and b/tests/__pycache__/test_projects.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_templates.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_templates.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..b6c80a0 Binary files /dev/null and b/tests/__pycache__/test_templates.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_types.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_types.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..2d3d888 Binary files /dev/null and b/tests/__pycache__/test_types.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_webhooks.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_webhooks.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..332e386 Binary files /dev/null and b/tests/__pycache__/test_webhooks.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..75179ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +"""Shared test fixtures.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._client import ApiClient + + +@pytest.fixture() +def mock_client() -> MagicMock: + """Return a mocked ApiClient whose HTTP methods can be configured per test.""" + client = MagicMock(spec=ApiClient) + client._base_url = "https://app.lettr.com/api" + return client diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..cd989cf --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,105 @@ +"""Tests for the ApiClient.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from lettr._client import ApiClient +from lettr._exceptions import LettrError + + +class TestApiClientRequest: + def test_strips_none_params(self) -> None: + client = ApiClient(api_key="lttr_test") + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "ok"} + + with patch.object(client._http, "request", return_value=mock_response) as mock_req: + client.get("/test", params={"a": 1, "b": None, "c": "x"}) + _, kwargs = mock_req.call_args + assert kwargs["params"] == {"a": 1, "c": "x"} + + client.close() + + def test_204_returns_none(self) -> None: + client = ApiClient(api_key="lttr_test") + mock_response = MagicMock() + mock_response.status_code = 204 + + with patch.object(client._http, "request", return_value=mock_response): + result = client.delete("/test") + assert result is None + + client.close() + + def test_http_error_raises_lettr_error(self) -> None: + import httpx + + client = ApiClient(api_key="lttr_test") + + with patch.object( + client._http, "request", side_effect=httpx.ConnectError("connection failed") + ), pytest.raises(LettrError, match="HTTP request failed"): + client.get("/test") + + client.close() + + def test_json_parse_failure_on_error_response(self) -> None: + client = ApiClient(api_key="lttr_test") + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.json.side_effect = ValueError("bad json") + + with patch.object( # noqa: SIM117 + client._http, "request", return_value=mock_response + ): + with pytest.raises(LettrError): + client.get("/test") + + client.close() + + +class TestApiClientGetNoAuth: + @patch("lettr._client.httpx.get") + def test_get_no_auth_succeeds(self, mock_get: MagicMock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"status": "ok"}} + mock_get.return_value = mock_response + + client = ApiClient(api_key="lttr_test") + result = client.get_no_auth("/health") + assert result == {"data": {"status": "ok"}} + + mock_get.assert_called_once() + call_kwargs = mock_get.call_args + assert "Authorization" not in call_kwargs.kwargs.get("headers", {}) + + client.close() + + @patch("lettr._client.httpx.get") + def test_get_no_auth_strips_none_params(self, mock_get: MagicMock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {}} + mock_get.return_value = mock_response + + client = ApiClient(api_key="lttr_test") + client.get_no_auth("/health", params={"a": 1, "b": None}) + + call_kwargs = mock_get.call_args + assert call_kwargs.kwargs["params"] == {"a": 1} + + client.close() + + +class TestApiClientContextManager: + def test_context_manager_closes(self) -> None: + client = ApiClient(api_key="lttr_test") + with patch.object(client, "close") as mock_close: + with client: + pass + mock_close.assert_called_once() diff --git a/tests/test_domains.py b/tests/test_domains.py new file mode 100644 index 0000000..695384e --- /dev/null +++ b/tests/test_domains.py @@ -0,0 +1,201 @@ +"""Tests for the Domains resource.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._types import Domain, DomainDnsVerification, DomainVerification +from lettr.resources.domains import Domains + + +@pytest.fixture() +def domains(mock_client: MagicMock) -> Domains: + return Domains(mock_client) + + +class TestList: + def test_list_domains(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "domains": [ + { + "domain": "example.com", + "status": "verified", + "status_label": "Verified", + "can_send": True, + "cname_status": "valid", + "dkim_status": "valid", + "created_at": "2025-01-01", + "updated_at": "2025-06-01", + } + ] + } + } + + result = domains.list() + assert len(result) == 1 + assert isinstance(result[0], Domain) + assert result[0].domain == "example.com" + assert result[0].can_send is True + + +class TestGet: + def test_get_domain(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "domain": "example.com", + "status": "verified", + "status_label": "Verified", + "can_send": True, + "cname_status": "valid", + "dkim_status": "valid", + "dmarc_status": "valid", + "spf_status": "valid", + "is_primary_domain": True, + "tracking_domain": "track.example.com", + "dns": {"cname": "cname.lettr.com"}, + "dns_provider": { + "provider": "cloudflare", + "provider_label": "Cloudflare", + "nameservers": ["ns1.cloudflare.com", "ns2.cloudflare.com"], + "error": None, + }, + "created_at": "2025-01-01", + "updated_at": "2025-06-01", + } + } + + result = domains.get("example.com") + assert isinstance(result, Domain) + assert result.dmarc_status == "valid" + assert result.spf_status == "valid" + assert result.is_primary_domain is True + assert result.dns_provider is not None + assert result.dns_provider.provider == "cloudflare" + assert result.dns_provider.provider_label == "Cloudflare" + + +class TestCreate: + def test_create_domain(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": { + "domain": "new.com", + "status": "pending", + "status_label": "Pending", + "dkim": { + "public": "MIGfMA0...", + "selector": "lettr", + "headers": "from:to:subject", + "signing_domain": "new.com", + }, + } + } + + result = domains.create("new.com") + assert result.domain == "new.com" + assert result.status == "pending" + assert result.dkim is not None + assert result.dkim.selector == "lettr" + assert result.dkim.signing_domain == "new.com" + + payload = mock_client.post.call_args.kwargs["json"] + assert payload == {"domain": "new.com"} + + def test_create_domain_tolerates_unknown_dkim_fields( + self, domains: Domains, mock_client: MagicMock + ) -> None: + """Regression: unknown server fields must not crash dataclass parsing.""" + mock_client.post.return_value = { + "data": { + "domain": "new.com", + "status": "pending", + "status_label": "Pending", + "dkim": { + "selector": "lettr", + "headers": "from:to:subject", + "signing_domain": "new.com", + "future_field_we_dont_know_about": "some_value", + "another_one": 42, + }, + } + } + + result = domains.create("new.com") + assert result.dkim is not None + assert result.dkim.selector == "lettr" + + +class TestDelete: + def test_delete_domain(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.delete.return_value = None + domains.delete("example.com") + mock_client.delete.assert_called_once_with("/domains/example.com") + + +class TestVerify: + def test_verify_domain(self, domains: Domains, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": { + "domain": "example.com", + "dkim_status": "valid", + "cname_status": "valid", + "dmarc_status": "valid", + "spf_status": "valid", + "is_primary_domain": True, + "ownership_verified": "2025-01-01", + "dns": { + "dkim_record": "v=DKIM1; k=rsa; p=MIGfMA0...", + "cname_record": "example.com.d.]sparkpostmail.com", + "dkim_error": None, + "cname_error": None, + }, + "dmarc": { + "is_valid": True, + "status": "valid", + "record": "v=DMARC1; p=reject", + "policy": "reject", + }, + "spf": { + "is_valid": True, + "status": "valid", + "record": "v=spf1 include:lettr.com ~all", + "includes_sparkpost": True, + }, + } + } + + result = domains.verify("example.com") + assert isinstance(result, DomainVerification) + assert result.dmarc_status == "valid" + assert result.spf_status == "valid" + assert result.is_primary_domain is True + assert result.dmarc is not None + assert result.dmarc.policy == "reject" + assert result.spf is not None + assert result.spf.includes_sparkpost is True + assert isinstance(result.dns, DomainDnsVerification) + assert result.dns.dkim_record == "v=DKIM1; k=rsa; p=MIGfMA0..." + assert result.dns.cname_error is None + + def test_verify_without_dmarc_spf_objects( + self, domains: Domains, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = { + "data": { + "domain": "example.com", + "dkim_status": "pending", + "cname_status": "pending", + "dmarc_status": "unverified", + "spf_status": "unverified", + "is_primary_domain": False, + } + } + + result = domains.verify("example.com") + assert result.dmarc is None + assert result.spf is None + assert result.dmarc_status == "unverified" + assert result.spf_status == "unverified" + assert result.is_primary_domain is False diff --git a/tests/test_emails.py b/tests/test_emails.py new file mode 100644 index 0000000..9fe35c6 --- /dev/null +++ b/tests/test_emails.py @@ -0,0 +1,413 @@ +"""Tests for the Emails resource.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._types import ( + Attachment, + EmailDetail, + EmailEventList, + EmailList, + EmailOptions, + ScheduledEmail, + SendEmailResponse, +) +from lettr.resources.emails import Emails + + +@pytest.fixture() +def emails(mock_client: MagicMock) -> Emails: + return Emails(mock_client) + + +class TestSend: + def test_send_minimal(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": {"request_id": "req_123", "accepted": 1, "rejected": 0} + } + + result = emails.send( + from_email="a@b.com", + to=["c@d.com"], + subject="Hi", + html="

Hello

", + ) + + assert isinstance(result, SendEmailResponse) + assert result.request_id == "req_123" + assert result.accepted == 1 + + payload = mock_client.post.call_args.kwargs["json"] + assert payload["from"] == "a@b.com" + assert payload["to"] == ["c@d.com"] + assert payload["subject"] == "Hi" + assert payload["html"] == "

Hello

" + + def test_send_with_all_options(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": {"request_id": "req_456", "accepted": 2, "rejected": 0} + } + + result = emails.send( + from_email="a@b.com", + to=["c@d.com", "e@f.com"], + subject="Test", + html="

Hi

", + text="Hi", + from_name="Sender", + cc=["cc@test.com"], + bcc=["bcc@test.com"], + reply_to="reply@test.com", + reply_to_name="Reply", + amp_html="

AMP

", + template_slug="welcome", + template_version=2, + project_id=10, + tag="onboarding", + metadata={"user_id": "123"}, + headers={"X-Custom": "value"}, + substitution_data={"name": "World"}, + options=EmailOptions(click_tracking=True, open_tracking=False), + attachments=[Attachment(name="f.pdf", type="application/pdf", data="base64data")], + ) + + assert result.accepted == 2 + payload = mock_client.post.call_args.kwargs["json"] + assert payload["tag"] == "onboarding" + assert payload["headers"] == {"X-Custom": "value"} + assert payload["cc"] == ["cc@test.com"] + assert payload["options"] == {"click_tracking": True, "open_tracking": False} + assert payload["attachments"] == [ + {"name": "f.pdf", "type": "application/pdf", "data": "base64data"} + ] + + def test_send_without_subject(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": {"request_id": "req_789", "accepted": 1, "rejected": 0} + } + + emails.send( + from_email="a@b.com", + to=["c@d.com"], + template_slug="welcome", + ) + + payload = mock_client.post.call_args.kwargs["json"] + assert "subject" not in payload + + +class TestList: + def test_list_default(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "events": { + "data": [ + { + "event_id": "ev1", + "type": "injection", + "timestamp": "2025-01-01T00:00:00Z", + "request_id": "req_1", + "subject": "Hello", + "rcpt_to": "a@b.com", + } + ], + "total_count": 1, + "from": "2025-01-01T00:00:00Z", + "to": "2025-01-15T00:00:00Z", + "pagination": {"next_cursor": "abc", "per_page": 25}, + } + } + } + + result = emails.list() + assert isinstance(result, EmailList) + assert len(result.results) == 1 + assert result.total_count == 1 + assert result.next_cursor == "abc" + assert result.date_from == "2025-01-01T00:00:00Z" + assert result.date_to == "2025-01-15T00:00:00Z" + assert result.results[0].type == "injection" + + def test_list_with_filters(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "events": { + "data": [], + "total_count": 0, + "from": None, + "to": None, + "pagination": {"per_page": 10}, + } + } + } + + emails.list( + per_page=10, + cursor="xyz", + recipients="a@b.com", + from_date="2025-01-01", + to_date="2025-12-31", + ) + + params = mock_client.get.call_args.kwargs["params"] + assert params["per_page"] == 10 + assert params["cursor"] == "xyz" + assert params["recipients"] == "a@b.com" + assert params["from"] == "2025-01-01" + assert params["to"] == "2025-12-31" + + def test_list_regression_nested_events_wrapper( + self, emails: Emails, mock_client: MagicMock + ) -> None: + """Regression: response uses data.events.data nesting per OpenAPI spec. + + Previously the SDK read from data.results and silently returned an + empty list when the API actually had emails. + """ + mock_client.get.return_value = { + "success": True, + "message": "Emails retrieved successfully.", + "data": { + "events": { + "data": [ + {"event_id": "e1", "type": "delivery", "timestamp": "t1"}, + {"event_id": "e2", "type": "delivery", "timestamp": "t2"}, + ], + "total_count": 2, + "from": "2024-01-05T00:00:00Z", + "to": "2024-01-15T23:59:59Z", + "pagination": {"next_cursor": None, "per_page": 25}, + } + }, + } + + result = emails.list() + assert result.total_count == 2 + assert len(result.results) == 2 + + +def _detail_envelope(**overrides: object) -> dict: + """Build a spec-shaped detail response (`GET /emails/{id}`).""" + base = { + "transmission_id": "req_123", + "state": "delivered", + "from": "sender@example.com", + "from_name": None, + "subject": "Hello", + "recipients": ["a@b.com"], + "num_recipients": 1, + "events": [], + } + base.update(overrides) + return {"data": base} + + +class TestGet: + def test_get_basic(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = _detail_envelope( + events=[{"event_id": "ev1", "type": "delivery", "rcpt_to": "a@b.com"}] + ) + + result = emails.get("req_123") + assert isinstance(result, EmailDetail) + assert result.transmission_id == "req_123" + assert result.state == "delivered" + assert result.from_email == "sender@example.com" + assert result.recipients == ["a@b.com"] + assert result.num_recipients == 1 + assert len(result.events) == 1 + assert result.events[0].type == "delivery" + + def test_get_with_date_filters(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = _detail_envelope() + + emails.get("req_123", from_date="2025-01-01", to_date="2025-06-01") + params = mock_client.get.call_args.kwargs["params"] + assert params["from"] == "2025-01-01" + assert params["to"] == "2025-06-01" + + def test_get_parses_nested_geo_ip(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = _detail_envelope( + events=[ + { + "event_id": "ev1", + "type": "open", + "geo_ip": { + "country": "US", + "city": "San Francisco", + "latitude": 37.7749, + "longitude": -122.4194, + }, + "user_agent_parsed": { + "agent_family": "Chrome", + "is_mobile": False, + }, + } + ] + ) + + result = emails.get("req_123") + event = result.events[0] + assert event.geo_ip is not None + assert event.geo_ip.country == "US" + assert event.geo_ip.latitude == 37.7749 + assert event.user_agent_parsed is not None + assert event.user_agent_parsed.agent_family == "Chrome" + assert event.user_agent_parsed.is_mobile is False + + +class TestListEvents: + def test_list_events_basic(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "events": { + "data": [ + {"event_id": "ev1", "type": "delivery"}, + {"event_id": "ev2", "type": "bounce"}, + ], + "total_count": 2, + "from": "2025-01-01T00:00:00Z", + "to": "2025-01-15T00:00:00Z", + "pagination": {"next_cursor": None, "per_page": 25}, + } + } + } + + result = emails.list_events() + assert isinstance(result, EmailEventList) + assert len(result.results) == 2 + assert result.results[0].type == "delivery" + assert result.date_from == "2025-01-01T00:00:00Z" + + def test_list_events_with_filters(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "events": { + "data": [], + "total_count": 0, + "from": None, + "to": None, + "pagination": {"per_page": 10}, + } + } + } + + emails.list_events( + events=["delivery", "bounce"], + recipients=["a@b.com"], + from_date="2025-01-01", + to_date="2025-12-31", + per_page=10, + cursor="abc", + transmissions="tr_123", + bounce_classes="10,30", + ) + + params = mock_client.get.call_args.kwargs["params"] + assert params["events"] == "delivery,bounce" + assert params["recipients"] == "a@b.com" + assert params["from"] == "2025-01-01" + assert params["transmissions"] == "tr_123" + assert params["bounce_classes"] == "10,30" + + +class TestSchedule: + def test_schedule_email(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "message": "Email scheduled for delivery.", + "data": { + "request_id": "12345678901234567890", + "accepted": 1, + "rejected": 0, + }, + } + + result = emails.schedule( + from_email="a@b.com", + to=["c@d.com"], + subject="Later", + html="

Hi

", + scheduled_at="2025-12-01T10:00:00Z", + tag="scheduled-test", + ) + + assert isinstance(result, SendEmailResponse) + assert result.request_id == "12345678901234567890" + assert result.accepted == 1 + assert result.rejected == 0 + + payload = mock_client.post.call_args.kwargs["json"] + assert payload["scheduled_at"] == "2025-12-01T10:00:00Z" + assert payload["tag"] == "scheduled-test" + mock_client.post.assert_called_once() + assert mock_client.post.call_args.args[0] == "/emails/scheduled" + + +class TestGetScheduled: + def test_get_scheduled(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "message": "Scheduled transmission retrieved successfully.", + "data": { + "transmission_id": "tr_123", + "state": "submitted", + "scheduled_at": "2025-12-01T10:00:00Z", + "from": "sender@example.com", + "from_name": "Sender", + "subject": "Scheduled Newsletter", + "recipients": ["recipient@example.com"], + "num_recipients": 1, + "events": [], + }, + } + + result = emails.get_scheduled("tr_123") + assert isinstance(result, ScheduledEmail) + assert result.transmission_id == "tr_123" + assert result.state == "submitted" + assert result.from_email == "sender@example.com" + assert result.from_name == "Sender" + assert result.subject == "Scheduled Newsletter" + assert result.recipients == ["recipient@example.com"] + assert result.num_recipients == 1 + assert result.events == [] + mock_client.get.assert_called_once_with("/emails/scheduled/tr_123") + + def test_get_scheduled_with_events(self, emails: Emails, mock_client: MagicMock) -> None: + """Regression: events array contains EmailEvent objects, not strings.""" + mock_client.get.return_value = { + "data": { + "transmission_id": "tr_123", + "state": "delivered", + "scheduled_at": None, + "from": "sender@example.com", + "from_name": None, + "subject": "Scheduled Newsletter", + "recipients": ["recipient@example.com"], + "num_recipients": 1, + "events": [ + { + "event_id": "evt-1", + "type": "delivery", + "timestamp": "2024-01-16T10:00:02.000Z", + "rcpt_to": "recipient@example.com", + } + ], + } + } + + result = emails.get_scheduled("tr_123") + assert len(result.events) == 1 + assert result.events[0].type == "delivery" + assert result.events[0].rcpt_to == "recipient@example.com" + + +class TestCancelScheduled: + def test_cancel_scheduled(self, emails: Emails, mock_client: MagicMock) -> None: + mock_client.delete.return_value = None + + result = emails.cancel_scheduled("tr_123") + assert result is None + mock_client.delete.assert_called_once_with("/emails/scheduled/tr_123") diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..a4af366 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,95 @@ +"""Tests for exception handling and raise_for_status.""" + +from __future__ import annotations + +import pytest + +from lettr._exceptions import ( + AuthenticationError, + BadRequestError, + ConflictError, + ForbiddenError, + LettrError, + NotFoundError, + RateLimitError, + ServerError, + ValidationError, + raise_for_status, +) + + +class TestRaiseForStatus: + def test_2xx_does_not_raise(self) -> None: + raise_for_status(200, {"message": "ok"}) + raise_for_status(201, {"message": "created"}) + raise_for_status(204, None) + + def test_400_raises_bad_request(self) -> None: + with pytest.raises(BadRequestError) as exc_info: + raise_for_status(400, {"message": "bad", "error_code": "invalid_domain"}) + assert exc_info.value.message == "bad" + assert exc_info.value.error_code == "invalid_domain" + + def test_401_raises_authentication_error(self) -> None: + with pytest.raises(AuthenticationError) as exc_info: + raise_for_status(401, {"message": "Unauthenticated"}) + assert exc_info.value.message == "Unauthenticated" + + def test_403_raises_forbidden_error(self) -> None: + with pytest.raises(ForbiddenError) as exc_info: + raise_for_status(403, {"message": "Forbidden"}) + assert exc_info.value.message == "Forbidden" + + def test_404_raises_not_found(self) -> None: + with pytest.raises(NotFoundError): + raise_for_status(404, {"message": "Not found"}) + + def test_409_raises_conflict(self) -> None: + with pytest.raises(ConflictError): + raise_for_status(409, {"message": "Already exists"}) + + def test_422_raises_validation_error(self) -> None: + body = { + "message": "Validation failed", + "errors": {"email": ["required"]}, + } + with pytest.raises(ValidationError) as exc_info: + raise_for_status(422, body) + assert exc_info.value.errors == {"email": ["required"]} + + def test_422_without_errors_field(self) -> None: + with pytest.raises(ValidationError) as exc_info: + raise_for_status(422, {"message": "Invalid"}) + assert exc_info.value.errors == {} + + def test_429_raises_rate_limit_error(self) -> None: + with pytest.raises(RateLimitError) as exc_info: + raise_for_status(429, {"message": "Quota exceeded", "error_code": "quota_exceeded"}) + assert exc_info.value.error_code == "quota_exceeded" + + def test_500_raises_server_error(self) -> None: + with pytest.raises(ServerError) as exc_info: + raise_for_status(500, {"message": "Internal error", "error_code": "send_error"}) + assert exc_info.value.error_code == "send_error" + + def test_502_raises_server_error(self) -> None: + with pytest.raises(ServerError): + raise_for_status(502, {"message": "Bad gateway", "error_code": "transmission_failed"}) + + def test_non_dict_body_raises_lettr_error(self) -> None: + with pytest.raises(LettrError, match="Unexpected error"): + raise_for_status(500, "not a dict") + + def test_unknown_status_raises_lettr_error(self) -> None: + with pytest.raises(LettrError, match="HTTP 418"): + raise_for_status(418, {"message": "I'm a teapot"}) + + +class TestValidationErrorStr: + def test_str_with_errors(self) -> None: + err = ValidationError("Failed", errors={"name": ["required", "too short"]}) + assert "name: required, too short" in str(err) + + def test_str_without_errors(self) -> None: + err = ValidationError("Failed") + assert str(err) == "Failed" diff --git a/tests/test_lettr.py b/tests/test_lettr.py new file mode 100644 index 0000000..c946a45 --- /dev/null +++ b/tests/test_lettr.py @@ -0,0 +1,65 @@ +"""Tests for the Lettr main client class.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr import AuthCheck, HealthCheck, Lettr + + +class TestLettrConstructor: + def test_empty_api_key_raises(self) -> None: + with pytest.raises(ValueError, match="api_key parameter is required"): + Lettr("") + + def test_creates_resources(self) -> None: + client = Lettr("lttr_test") + assert hasattr(client, "emails") + assert hasattr(client, "domains") + assert hasattr(client, "templates") + assert hasattr(client, "webhooks") + assert hasattr(client, "projects") + client.close() + + def test_repr(self) -> None: + client = Lettr("lttr_test") + assert "app.lettr.com" in repr(client) + client.close() + + +class TestLettrContextManager: + def test_context_manager(self) -> None: + with Lettr("lttr_test") as client: + assert client is not None + + +class TestLettrHealth: + def test_health(self) -> None: + client = Lettr("lttr_test") + client._client.get_no_auth = MagicMock( + return_value={"data": {"status": "ok", "timestamp": "2024-01-15T10:30:00Z"}} + ) + + result = client.health() + assert isinstance(result, HealthCheck) + assert result.status == "ok" + assert result.timestamp == "2024-01-15T10:30:00Z" + client._client.get_no_auth.assert_called_once_with("/health") + client.close() + + +class TestLettrAuthCheck: + def test_auth_check(self) -> None: + client = Lettr("lttr_test") + client._client.get = MagicMock( + return_value={"data": {"team_id": 1, "timestamp": "2024-01-15T10:30:00Z"}} + ) + + result = client.auth_check() + assert isinstance(result, AuthCheck) + assert result.team_id == 1 + assert result.timestamp == "2024-01-15T10:30:00Z" + client._client.get.assert_called_once_with("/auth/check") + client.close() diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..0a05586 --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,64 @@ +"""Tests for the Projects resource.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._types import ProjectList +from lettr.resources.projects import Projects + + +@pytest.fixture() +def projects(mock_client: MagicMock) -> Projects: + return Projects(mock_client) + + +class TestList: + def test_list_projects(self, projects: Projects, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "projects": [ + { + "id": 1, + "name": "Main", + "team_id": 10, + "emoji": "🚀", + "created_at": "2025-01-01", + "updated_at": "2025-06-01", + } + ], + "pagination": { + "total": 1, + "per_page": 25, + "current_page": 1, + "last_page": 1, + }, + } + } + + result = projects.list() + assert isinstance(result, ProjectList) + assert len(result.projects) == 1 + assert result.projects[0].name == "Main" + assert result.projects[0].emoji == "🚀" + assert result.total == 1 + + def test_list_with_pagination(self, projects: Projects, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "projects": [], + "pagination": { + "total": 0, + "per_page": 10, + "current_page": 2, + "last_page": 2, + }, + } + } + + projects.list(per_page=10, page=2) + params = mock_client.get.call_args.kwargs["params"] + assert params["per_page"] == 10 + assert params["page"] == 2 diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..5e63cd8 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,193 @@ +"""Tests for the Templates resource.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._types import Template, TemplateHtml, TemplateList, TemplateMergeTags +from lettr.resources.templates import Templates + + +@pytest.fixture() +def templates(mock_client: MagicMock) -> Templates: + return Templates(mock_client) + + +class TestList: + def test_list_templates(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "templates": [ + { + "id": 1, + "name": "Welcome", + "slug": "welcome", + "project_id": 10, + "folder_id": 5, + "created_at": "2025-01-01", + "updated_at": "2025-06-01", + } + ], + "pagination": { + "total": 1, + "per_page": 25, + "current_page": 1, + "last_page": 1, + }, + } + } + + result = templates.list(project_id=10) + assert isinstance(result, TemplateList) + assert len(result.templates) == 1 + assert result.templates[0].slug == "welcome" + + params = mock_client.get.call_args.kwargs["params"] + assert params["project_id"] == 10 + + +class TestGet: + def test_get_template(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "id": 1, + "name": "Welcome", + "slug": "welcome", + "project_id": 10, + "folder_id": 5, + "created_at": "2025-01-01", + "active_version": 3, + "versions_count": 3, + "html": "

Hi

", + "updated_at": "2025-06-01", + } + } + + result = templates.get("welcome", project_id=10) + assert isinstance(result, Template) + assert result.active_version == 3 + assert result.html == "

Hi

" + + +class TestCreate: + def test_create_template(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": { + "id": 2, + "name": "New", + "slug": "new", + "project_id": 10, + "folder_id": 5, + "active_version": 1, + "merge_tags": [ + {"key": "name", "required": True, "type": "text"}, + { + "key": "items", + "required": False, + "type": "loop", + "children": [{"key": "title", "type": "text"}], + }, + ], + "created_at": "2025-01-01", + } + } + + result = templates.create(name="New", html="

{{name}}

", project_id=10) + assert result.slug == "new" + assert result.merge_tags is not None + assert len(result.merge_tags) == 2 + assert result.merge_tags[0].key == "name" + assert result.merge_tags[0].required is True + assert result.merge_tags[1].children is not None + assert result.merge_tags[1].children[0].key == "title" + + +class TestUpdate: + def test_update_template(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.put.return_value = { + "data": { + "id": 1, + "name": "Updated", + "slug": "welcome", + "project_id": 10, + "folder_id": 5, + "active_version": 4, + "created_at": "2025-01-01", + "updated_at": "2025-06-15", + } + } + + result = templates.update("welcome", name="Updated") + assert result.name == "Updated" + assert result.updated_at == "2025-06-15" + + payload = mock_client.put.call_args.kwargs["json"] + assert payload == {"name": "Updated"} + + +class TestDelete: + def test_delete_template(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.delete.return_value = None + templates.delete("welcome", project_id=10) + mock_client.delete.assert_called_once() + params = mock_client.delete.call_args.kwargs["params"] + assert params["project_id"] == 10 + + +class TestGetMergeTags: + def test_get_merge_tags(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "template_slug": "welcome", + "version": 3, + "project_id": 10, + "merge_tags": [ + {"key": "name", "required": True, "type": "text"}, + ], + } + } + + result = templates.get_merge_tags("welcome", project_id=10, version=3) + assert isinstance(result, TemplateMergeTags) + assert result.template_slug == "welcome" + assert result.version == 3 + assert result.project_id == 10 + assert len(result.merge_tags) == 1 + + +class TestGetHtml: + def test_get_html(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": { + "html": "

Welcome!

", + "merge_tags": [ + {"key": "name", "name": "name", "required": True}, + ], + "subject": "Welcome Email", + } + } + + result = templates.get_html(project_id=10, slug="welcome") + assert isinstance(result, TemplateHtml) + assert result.html == "

Welcome!

" + assert result.subject == "Welcome Email" + assert result.merge_tags is not None + assert len(result.merge_tags) == 1 + assert result.merge_tags[0].key == "name" + assert result.merge_tags[0].name == "name" + + params = mock_client.get.call_args.kwargs["params"] + assert params["project_id"] == 10 + assert params["slug"] == "welcome" + assert mock_client.get.call_args.args[0] == "/templates/html" + + def test_get_html_empty_merge_tags(self, templates: Templates, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": {"html": "

Hello

", "merge_tags": []}} + + result = templates.get_html(project_id=5, slug="simple") + assert isinstance(result, TemplateHtml) + assert result.html == "

Hello

" + assert result.merge_tags == [] + assert result.subject is None diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..2da38ef --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,72 @@ +"""Tests for type definitions.""" + +from __future__ import annotations + +from lettr._types import EmailOptions, GeoIp, ScheduledEmail, UserAgentParsed + + +class TestEmailOptions: + def test_to_dict_filters_none(self) -> None: + opts = EmailOptions(click_tracking=True, open_tracking=None, transactional=False) + result = opts.to_dict() + assert result == {"click_tracking": True, "transactional": False} + + def test_to_dict_empty(self) -> None: + opts = EmailOptions() + assert opts.to_dict() == {} + + def test_to_dict_all_set(self) -> None: + opts = EmailOptions( + click_tracking=True, + open_tracking=False, + transactional=True, + inline_css=False, + perform_substitutions=True, + ) + result = opts.to_dict() + assert len(result) == 5 + assert result["perform_substitutions"] is True + + +class TestGeoIp: + def test_all_optional(self) -> None: + geo = GeoIp() + assert geo.country is None + assert geo.latitude is None + + def test_with_values(self) -> None: + geo = GeoIp(country="US", latitude=37.77, longitude=-122.42) + assert geo.country == "US" + assert geo.latitude == 37.77 + + +class TestUserAgentParsed: + def test_all_optional(self) -> None: + ua = UserAgentParsed() + assert ua.agent_family is None + assert ua.is_mobile is None + + def test_with_values(self) -> None: + ua = UserAgentParsed(agent_family="Chrome", is_mobile=True) + assert ua.agent_family == "Chrome" + assert ua.is_mobile is True + + +class TestScheduledEmail: + def test_creation(self) -> None: + se = ScheduledEmail( + transmission_id="tr_123", + state="submitted", + from_email="sender@example.com", + subject="Hello", + recipients=["a@b.com"], + num_recipients=1, + events=[], + scheduled_at="2025-12-01T10:00:00Z", + ) + assert se.transmission_id == "tr_123" + assert se.state == "submitted" + assert se.from_email == "sender@example.com" + assert se.num_recipients == 1 + assert se.events == [] + assert se.from_name is None diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..dbcef51 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,136 @@ +"""Tests for the Webhooks resource.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._types import Webhook +from lettr.resources.webhooks import Webhooks + + +@pytest.fixture() +def webhooks(mock_client: MagicMock) -> Webhooks: + return Webhooks(mock_client) + + +WEBHOOK_DATA = { + "id": "wh_123", + "name": "My Hook", + "url": "https://example.com/hook", + "enabled": True, + "auth_type": "none", + "has_auth_credentials": False, + "event_types": ["delivery", "bounce"], + "last_successful_at": "2025-06-01", + "last_failure_at": None, + "last_status": "200", +} + + +class TestList: + def test_list_webhooks(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": {"webhooks": [WEBHOOK_DATA]}} + + result = webhooks.list() + assert len(result) == 1 + assert isinstance(result[0], Webhook) + assert result[0].id == "wh_123" + assert result[0].event_types == ["delivery", "bounce"] + + +class TestGet: + def test_get_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": WEBHOOK_DATA} + + result = webhooks.get("wh_123") + assert isinstance(result, Webhook) + assert result.name == "My Hook" + mock_client.get.assert_called_once_with("/webhooks/wh_123") + + +class TestCreate: + def test_create_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.post.return_value = {"data": WEBHOOK_DATA} + + result = webhooks.create( + name="My Hook", + url="https://example.com/hook", + auth_type="none", + events_mode="selected", + events=["delivery", "bounce"], + ) + + assert isinstance(result, Webhook) + assert result.id == "wh_123" + + payload = mock_client.post.call_args.kwargs["json"] + assert payload["name"] == "My Hook" + assert payload["auth_type"] == "none" + assert payload["events_mode"] == "selected" + assert payload["events"] == ["delivery", "bounce"] + + def test_create_with_basic_auth(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": {**WEBHOOK_DATA, "auth_type": "basic", "has_auth_credentials": True} + } + + webhooks.create( + name="Secure Hook", + url="https://example.com/hook", + auth_type="basic", + events_mode="all", + auth_username="user", + auth_password="pass", + ) + + payload = mock_client.post.call_args.kwargs["json"] + assert payload["auth_username"] == "user" + assert payload["auth_password"] == "pass" + + def test_create_with_oauth2(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.post.return_value = { + "data": {**WEBHOOK_DATA, "auth_type": "oauth2", "has_auth_credentials": True} + } + + webhooks.create( + name="OAuth Hook", + url="https://example.com/hook", + auth_type="oauth2", + events_mode="all", + oauth_client_id="cid", + oauth_client_secret="csecret", + oauth_token_url="https://auth.example.com/token", + ) + + payload = mock_client.post.call_args.kwargs["json"] + assert payload["oauth_client_id"] == "cid" + assert payload["oauth_client_secret"] == "csecret" + assert payload["oauth_token_url"] == "https://auth.example.com/token" + + +class TestUpdate: + def test_update_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.put.return_value = {"data": {**WEBHOOK_DATA, "name": "Renamed Hook"}} + + result = webhooks.update("wh_123", name="Renamed Hook", active=False) + assert result.name == "Renamed Hook" + + payload = mock_client.put.call_args.kwargs["json"] + assert payload == {"name": "Renamed Hook", "active": False} + + def test_update_partial(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.put.return_value = {"data": WEBHOOK_DATA} + + webhooks.update("wh_123", events=["delivery"]) + payload = mock_client.put.call_args.kwargs["json"] + assert payload == {"events": ["delivery"]} + assert "name" not in payload + + +class TestDelete: + def test_delete_webhook(self, webhooks: Webhooks, mock_client: MagicMock) -> None: + mock_client.delete.return_value = None + webhooks.delete("wh_123") + mock_client.delete.assert_called_once_with("/webhooks/wh_123")