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")