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

## [Unreleased]

## [1.4.0] - 2026-05-28

### Added
- `CampaignDetail` type — a `Campaign` subclass that adds the rendered
`html_content` field. Returned by `client.campaigns.get(id)`.

### Changed
- `client.campaigns.get(id)` is now typed as returning `CampaignDetail`
(previously `Campaign`). `CampaignDetail` inherits from `Campaign`, so
existing callers that read summary fields keep working unchanged.

### Removed
- `Campaign.html_content` — moved to `CampaignDetail`. The field was only
ever populated by `get()`; on `list()` / `send()` / `schedule()` /
`unschedule()` it was always `None`, so reading it on those return
values was a dead branch the type system silently encouraged. Move
any `html_content` access to a `get()` result.

## [1.3.0] - 2026-05-27

### Added
Expand Down Expand Up @@ -172,7 +190,8 @@ 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.3.0...HEAD
[Unreleased]: https://github.com/lettr/lettr-python/compare/v1.4.0...HEAD
[1.4.0]: https://github.com/lettr/lettr-python/compare/v1.3.0...v1.4.0
[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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,9 @@ 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
# Get a single campaign — returns a CampaignDetail with the rendered body.
# (`list`, `send`, `schedule`, `unschedule` return the base `Campaign`,
# which does not carry `html_content`.)
campaign = client.campaigns.get(page.campaigns[0].id)
print(campaign.html_content)

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.3.0"
version = "1.4.0"
description = "Official Python SDK for the Lettr Email API"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 2 additions & 0 deletions src/lettr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
BulkListsAttachResult,
BulkListsDetachResult,
Campaign,
CampaignDetail,
CampaignEvent,
CampaignEventPage,
CampaignPage,
Expand Down Expand Up @@ -121,6 +122,7 @@
"BulkListsAttachResult",
"BulkListsDetachResult",
"Campaign",
"CampaignDetail",
"CampaignEvent",
"CampaignEventPage",
"CampaignPage",
Expand Down
18 changes: 15 additions & 3 deletions src/lettr/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,9 +690,11 @@ class CampaignStats:
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.
Returned by :meth:`Campaigns.list`, :meth:`Campaigns.send`,
:meth:`Campaigns.schedule`, and :meth:`Campaigns.unschedule`. The
rendered email body lives on :class:`CampaignDetail` (returned by
:meth:`Campaigns.get`) so callers of the list/action endpoints aren't
handed an attribute that the API never populates there.
"""

id: str
Expand All @@ -708,6 +710,16 @@ class Campaign:
scheduled_at: str | None = None
total_recipients: int | None = None
sent_at: str | None = None


@dataclass
class CampaignDetail(Campaign):
"""A campaign plus its rendered HTML body.

Returned only by :meth:`Campaigns.get`. Inherits every :class:`Campaign`
field so existing summary-shaped consumers keep working unchanged.
"""

html_content: str | None = None


Expand Down
43 changes: 23 additions & 20 deletions src/lettr/resources/campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from __future__ import annotations

from datetime import datetime
from typing import Any
from typing import Any, TypeVar

from .._client import ApiClient
from .._types import (
Campaign,
CampaignDetail,
CampaignEvent,
CampaignEventPage,
CampaignPage,
Expand All @@ -20,19 +21,19 @@
# Parsers
# ---------------------------------------------------------------------------

_C = TypeVar("_C", bound=Campaign)

def _parse_campaign(d: dict[str, Any]) -> Campaign:
"""Parse a campaign payload (CampaignSummary or CampaignDetail).

The ``html_content`` field is only present on the detail (``get``)
response; for list responses it stays ``None``. Unknown keys are
silently dropped via :func:`_from_dict` so the parser is
forward-compatible.
def _parse_campaign_as(cls: type[_C], d: dict[str, Any]) -> _C:
"""Parse a campaign payload into ``cls`` (Campaign or CampaignDetail).

Pass :class:`Campaign` for list/action endpoints and
:class:`CampaignDetail` for the detail endpoint. Unknown keys are
dropped by :func:`_from_dict`, so the list/action paths can't
accidentally surface a stray ``html_content`` if the API ever
includes one.
"""
return _from_dict(
Campaign,
{**d, "stats": _from_dict(CampaignStats, d["stats"])},
)
return _from_dict(cls, {**d, "stats": _from_dict(CampaignStats, d["stats"])})


def _parse_event(d: dict[str, Any]) -> CampaignEvent:
Expand All @@ -46,11 +47,11 @@ def _parse_action_response(body: dict[str, Any]) -> Campaign | None:
server omits it in the rare case the campaign can't be re-read after
the action (e.g. it was concurrently deleted). ``None`` here means
"absent key", not "empty object": a spec-violating ``data: {}`` is
intentionally left to raise inside :func:`_parse_campaign` rather than
being silently swallowed.
intentionally left to raise inside :func:`_parse_campaign_as` rather
than being silently swallowed.
"""
data = body.get("data")
return _parse_campaign(data) if data is not None else None
return _parse_campaign_as(Campaign, data) if data is not None else None


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -105,18 +106,20 @@ def list(
body = self._client.get("/campaigns", params=params)
data = body["data"]
return CampaignPage(
campaigns=[_parse_campaign(item) for item in data["campaigns"]],
campaigns=[_parse_campaign_as(Campaign, item) for item in data["campaigns"]],
**_pagination_kwargs(data["pagination"]),
)

def get(self, campaign_id: str) -> Campaign:
"""Get a single campaign by ID, including its rendered HTML content.
def get(self, campaign_id: str) -> CampaignDetail:
"""Get a single campaign by ID, including its rendered HTML body.

The returned :class:`Campaign` has ``html_content`` populated; list
responses leave it as ``None``.
Returns a :class:`CampaignDetail` (a :class:`Campaign` subclass)
whose ``html_content`` field carries the rendered email; the
list/action endpoints return the base :class:`Campaign` and do
not expose ``html_content`` at all.
"""
body = self._client.get(f"/campaigns/{campaign_id}")
return _parse_campaign(body["data"])
return _parse_campaign_as(CampaignDetail, body["data"])

def list_events(
self,
Expand Down
28 changes: 21 additions & 7 deletions tests/test_campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from lettr._types import (
Campaign,
CampaignDetail,
CampaignEventPage,
CampaignPage,
CampaignStats,
Expand Down Expand Up @@ -98,8 +99,8 @@ def test_list(self, campaigns: Campaigns, mock_client: MagicMock) -> None:
assert page.campaigns[0].name == "Spring Sale"
assert isinstance(page.campaigns[0].stats, CampaignStats)
assert page.campaigns[0].stats.unique_opens == 45
# list responses do not include the rendered body
assert page.campaigns[0].html_content is None
# list responses return the base type, not the detail variant
assert type(page.campaigns[0]) is Campaign
assert page.total == 1
assert page.per_page == 20
mock_client.get.assert_called_once_with("/campaigns", params={})
Expand Down Expand Up @@ -128,7 +129,17 @@ def test_list_handles_minimal_payload(
assert c.scheduled_at is None
assert c.total_recipients is None
assert c.sent_at is None
assert c.html_content is None

def test_list_drops_unexpected_html_content(
self, campaigns: Campaigns, mock_client: MagicMock
) -> None:
# Regression guard: even if the API ever leaks html_content into
# a list item, _from_dict's field-set filter must strip it before
# construction so the base Campaign instance has no such attribute.
polluted = {**CAMPAIGN_DATA, "html_content": "<h1>leak</h1>"}
mock_client.get.return_value = {"data": {"campaigns": [polluted], "pagination": PAGINATION}}
page = campaigns.list()
assert not hasattr(page.campaigns[0], "html_content")


# ---------------------------------------------------------------------------
Expand All @@ -140,7 +151,9 @@ class TestGet:
def test_get(self, campaigns: Campaigns, mock_client: MagicMock) -> None:
mock_client.get.return_value = {"data": CAMPAIGN_DETAIL_DATA}
result = campaigns.get("camp_1")
assert isinstance(result, Campaign)
# CampaignDetail IS-A Campaign — substitutability preserved for
# existing summary-shaped callers.
assert isinstance(result, CampaignDetail)
assert result.id == "camp_1"
assert result.html_content == "<h1>Hi</h1>"
assert result.stats.clicks == 20
Expand Down Expand Up @@ -201,7 +214,8 @@ class TestSend:
def test_send_returns_campaign(self, campaigns: Campaigns, mock_client: MagicMock) -> None:
mock_client.post.return_value = {"message": "Sending.", "data": CAMPAIGN_DATA}
result = campaigns.send("camp_1")
assert isinstance(result, Campaign)
# Action endpoints return the base Campaign, never the detail variant.
assert type(result) is Campaign
assert result.id == "camp_1"
mock_client.post.assert_called_once_with("/campaigns/camp_1/send")

Expand All @@ -217,7 +231,7 @@ def test_schedule_returns_campaign(self, campaigns: Campaigns, mock_client: Magi
scheduled = {**CAMPAIGN_DATA, "status": "scheduled"}
mock_client.post.return_value = {"message": "Scheduled.", "data": scheduled}
result = campaigns.schedule("camp_1", scheduled_at="2026-06-01T09:00:00+00:00")
assert isinstance(result, Campaign)
assert type(result) is Campaign
assert result.status == "scheduled"
mock_client.post.assert_called_once_with(
"/campaigns/camp_1/schedule",
Expand Down Expand Up @@ -247,7 +261,7 @@ def test_unschedule_returns_campaign(
drafted = {**CAMPAIGN_DATA, "status": "draft"}
mock_client.post.return_value = {"message": "Unscheduled.", "data": drafted}
result = campaigns.unschedule("camp_1")
assert isinstance(result, Campaign)
assert type(result) is Campaign
assert result.status == "draft"
mock_client.post.assert_called_once_with("/campaigns/camp_1/unschedule")

Expand Down
Loading