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
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.3.0] - 2026-05-27

### Added
- Campaigns API — wraps the `/campaigns` endpoints under `client.campaigns`:
- `list` — paginated, with an optional `status` filter
- `get` — single campaign including rendered `html_content`
- `list_events` — cursor-paginated engagement events, with
`event_type` / `email` / `start_date` / `end_date` / `limit` filters
- `send` — send a campaign now
- `schedule` — schedule a campaign (`scheduled_at`, ISO 8601, future)
- `unschedule` — cancel a scheduled send
`send` / `schedule` / `unschedule` return the updated `Campaign`, or `None`
in the rare case the API omits it (e.g. the campaign was concurrently
deleted).

## [1.2.0] - 2026-05-26

### Added
Expand Down Expand Up @@ -157,7 +172,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`ValidationError`, `NotFoundError`, `ConflictError`, `BadRequestError`,
`ServerError`)

[Unreleased]: https://github.com/lettr/lettr-python/compare/v1.1.0...HEAD
[Unreleased]: https://github.com/lettr/lettr-python/compare/v1.3.0...HEAD
[1.3.0]: https://github.com/lettr/lettr-python/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/lettr/lettr-python/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/lettr/lettr-python/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/lettr/lettr-python/compare/v0.3.0...v1.0.0
[0.3.0]: https://github.com/lettr/lettr-python/compare/v0.2.0...v0.3.0
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,38 @@ for project in project_list.projects:
print(f"{project.emoji} {project.name}")
```

### Campaigns

Campaigns are authored in the Lettr app; the SDK lists them, reads them,
inspects their engagement events, and triggers delivery.

```python
# List campaigns (optionally filter by status)
page = client.campaigns.list(status="sent", per_page=50)
for campaign in page.campaigns:
print(f"{campaign.name}: {campaign.status} — {campaign.stats.unique_opens} opens")

# Get a single campaign, including rendered HTML
campaign = client.campaigns.get(page.campaigns[0].id)
print(campaign.html_content)

# List engagement events (cursor-based pagination)
events = client.campaigns.list_events(campaign.id, event_type="open")
for event in events.events:
print(f"{event.timestamp} {event.event_type} {event.email}")
if events.next_cursor:
more = client.campaigns.list_events(campaign.id, cursor=events.next_cursor)

# Send now
client.campaigns.send(campaign.id)

# Schedule for later (ISO 8601, must be in the future)
client.campaigns.schedule(campaign.id, scheduled_at="2026-06-01T09:00:00+00:00")

# Cancel a scheduled send
client.campaigns.unschedule(campaign.id)
```

## Error Handling

The SDK raises typed exceptions for all API errors:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "lettr"
version = "1.2.0"
version = "1.3.0"
description = "Official Python SDK for the Lettr Email API"
readme = "README.md"
license = "MIT"
Expand Down
20 changes: 17 additions & 3 deletions src/lettr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
BulkDeleteResult,
BulkListsAttachResult,
BulkListsDetachResult,
Campaign,
CampaignEvent,
CampaignEventPage,
CampaignPage,
CampaignStats,
DkimInfo,
DmarcValidationResult,
DnsProvider,
Expand Down Expand Up @@ -76,11 +81,12 @@
UserAgentParsed,
Webhook,
)
from .resources import Audience, Domains, Emails, Projects, Templates, Webhooks

__version__ = "1.2.0"
from ._version import __version__
from .resources import Audience, Campaigns, Domains, Emails, Projects, Templates, Webhooks

__all__ = [
# Version
"__version__",
# Client
"Lettr",
# Exceptions
Expand Down Expand Up @@ -114,6 +120,11 @@
"BulkDeleteResult",
"BulkListsAttachResult",
"BulkListsDetachResult",
"Campaign",
"CampaignEvent",
"CampaignEventPage",
"CampaignPage",
"CampaignStats",
"DkimInfo",
"DmarcValidationResult",
"DnsProvider",
Expand Down Expand Up @@ -210,6 +221,9 @@ def __init__(
self.audience = Audience(self._client)
"""Audience management — lists, contacts, topics, properties, segments."""

self.campaigns = Campaigns(self._client)
"""Campaign operations — list, get, events, send, schedule, unschedule."""

def health(self) -> HealthCheck:
"""Check API health. No authentication required.

Expand Down
6 changes: 4 additions & 2 deletions src/lettr/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
import httpx

from ._exceptions import LettrError, raise_for_status
from ._version import __version__

DEFAULT_BASE_URL = "https://app.lettr.com/api"
DEFAULT_TIMEOUT = 30.0
USER_AGENT = f"lettr-python/{__version__}"


class ApiClient:
Expand All @@ -31,7 +33,7 @@ def __init__(
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "lettr-python/1.2.0",
"User-Agent": USER_AGENT,
},
)

Expand Down Expand Up @@ -107,7 +109,7 @@ def get_no_auth(self, path: str, *, params: dict[str, Any] | None = None) -> Any
timeout=self._timeout,
headers={
"Accept": "application/json",
"User-Agent": "lettr-python/1.2.0",
"User-Agent": USER_AGENT,
},
)
except httpx.HTTPError as exc:
Expand Down
94 changes: 94 additions & 0 deletions src/lettr/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ def _from_dict(cls: type[T], data: dict[str, Any]) -> T:
return cls(**{k: v for k, v in data.items() if k in known})


def _pagination_kwargs(p: dict[str, Any]) -> dict[str, int]:
"""Extract the standard four-field page-based pagination kwargs.

The Laravel-style paginator returns ``{total, per_page, current_page,
last_page}`` on every list endpoint. Helper exists so each ``*Page``
parser unpacks the same four keys consistently rather than spelling them
out by hand.
"""
return {
"total": p["total"],
"per_page": p["per_page"],
"current_page": p["current_page"],
"last_page": p["last_page"],
}


# ---------------------------------------------------------------------------
# Common / shared types
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -648,3 +664,81 @@ class BulkListsDetachResult:
detached: int
not_present: int
total_pairs: int


# ---------------------------------------------------------------------------
# Campaigns
# ---------------------------------------------------------------------------


@dataclass
class CampaignStats:
"""Aggregated engagement statistics for a campaign."""

injections: int
deliveries: int
bounces: int
spam_complaints: int
opens: int
unique_opens: int
clicks: int
unique_clicks: int
unsubscribes: int


@dataclass
class Campaign:
"""A campaign with embedded engagement stats.

``html_content`` is populated by ``client.campaigns.get(id)`` (which
returns the full rendered email body) and left as ``None`` on
``client.campaigns.list()`` responses, which omit the heavy field.
"""

id: str
name: str
status: str
sent_count: int
created_at: str
stats: CampaignStats
subject: str | None = None
from_email: str | None = None
from_name: str | None = None
reply_to: str | None = None
scheduled_at: str | None = None
total_recipients: int | None = None
sent_at: str | None = None
html_content: str | None = None


@dataclass
class CampaignPage:
"""Paginated list of campaigns."""

campaigns: list[Campaign]
total: int
per_page: int
current_page: int
last_page: int


@dataclass
class CampaignEvent:
"""A single campaign engagement event."""

event_id: str
event_type: str
email: str
timestamp: str
bounce_class: str | None = None
reason: str | None = None
target_link_url: str | None = None
user_agent: str | None = None


@dataclass
class CampaignEventPage:
"""Cursor-paginated list of campaign events."""

events: list[CampaignEvent]
next_cursor: str | None = None
17 changes: 17 additions & 0 deletions src/lettr/_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Single source of truth for the package version.

The version itself is declared in ``pyproject.toml``; this module reads it
back at runtime via :func:`importlib.metadata.version` so that
:data:`__version__` and the HTTP ``User-Agent`` header stay in sync with the
installed distribution automatically. No other file should hardcode the
version string.
"""

from __future__ import annotations

from importlib.metadata import PackageNotFoundError, version

try:
__version__: str = version("lettr")
except PackageNotFoundError: # pragma: no cover — running from an uninstalled checkout
__version__ = "0.0.0+unknown"
3 changes: 2 additions & 1 deletion src/lettr/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Lettr API resource modules."""

from .audience import Audience
from .campaigns import Campaigns
from .domains import Domains
from .emails import Emails
from .projects import Projects
from .templates import Templates
from .webhooks import Webhooks

__all__ = ["Audience", "Domains", "Emails", "Projects", "Templates", "Webhooks"]
__all__ = ["Audience", "Campaigns", "Domains", "Emails", "Projects", "Templates", "Webhooks"]
Loading
Loading