Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 1 addition & 20 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
101 changes: 101 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
104 changes: 100 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,6 +70,8 @@ response = client.emails.send(
subject="Your Order Confirmation",
html="<h1>Order Confirmed</h1>",
text="Order Confirmed",
tag="order-confirmation",
headers={"X-Order-Id": "12345"},
options=lettr.EmailOptions(
click_tracking=True,
open_tracking=True,
Expand Down Expand Up @@ -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",
Expand All @@ -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="<p>This was scheduled!</p>",
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
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
Loading
Loading