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

## [Unreleased]

## [1.2.0] - 2026-05-26

### Added
- Audience API — wraps all `/audience/*` endpoints under
`client.audience.<sub>`:
- `client.audience.lists` — list, get, create, update, delete, bulk_delete
- `client.audience.contacts` — list (with `search`/`status`/`list_id`/
`segment_id` filters), get, create (with optional `double_opt_in`),
update, delete, bulk_create, plus membership ops `add_to_list` /
`remove_from_list` / `subscribe_to_topic` / `unsubscribe_from_topic`
(keyword-only to prevent `contact_id` / `list_id` transposition) and
`bulk_attach_lists` / `bulk_detach_lists`
- `client.audience.topics` — list, get, create, update, delete
- `client.audience.properties` — list, get, create, update, delete
- `client.audience.segments` — list (with `list_id` filter), get, create,
update, delete
- `UNSET` sentinel exported from `lettr` — pass to `topics.update`,
`properties.update`, or `segments.update` to leave a field unchanged;
pass `None` to explicitly clear a nullable field (`description`,
`fallback_value`, `list_id`).
- `ApiClient.patch()` method and `json` body support on `ApiClient.delete()`
to back the new audience endpoints (PATCH for updates, DELETE with a
body for bulk endpoints).

### Notes
- `audience/confirm/{token}` is intentionally not wrapped (intended for
end-user confirmation flow, not SDK usage).

## [1.1.0] - 2026-04-22

### Changed
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.1.0"
version = "1.2.0"
description = "Official Python SDK for the Lettr Email API"
readme = "README.md"
license = "MIT"
Expand Down
42 changes: 40 additions & 2 deletions src/lettr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,25 @@
ValidationError,
)
from ._types import (
UNSET,
Attachment,
AudienceContact,
AudienceContactListRef,
AudienceContactPage,
AudienceContactTopicRef,
AudienceList,
AudienceListPage,
AudienceProperty,
AudiencePropertyPage,
AudienceSegment,
AudienceSegmentPage,
AudienceTopic,
AudienceTopicPage,
AuthCheck,
BulkContactImportResult,
BulkDeleteResult,
BulkListsAttachResult,
BulkListsDetachResult,
DkimInfo,
DmarcValidationResult,
DnsProvider,
Expand Down Expand Up @@ -59,9 +76,9 @@
UserAgentParsed,
Webhook,
)
from .resources import Domains, Emails, Projects, Templates, Webhooks
from .resources import Audience, Domains, Emails, Projects, Templates, Webhooks

__version__ = "1.1.0"
__version__ = "1.2.0"

__all__ = [
# Client
Expand All @@ -76,9 +93,27 @@
"RateLimitError",
"ServerError",
"ValidationError",
# Sentinels
"UNSET",
# Types
"Attachment",
"AudienceContact",
"AudienceContactListRef",
"AudienceContactPage",
"AudienceContactTopicRef",
"AudienceList",
"AudienceListPage",
"AudienceProperty",
"AudiencePropertyPage",
"AudienceSegment",
"AudienceSegmentPage",
"AudienceTopic",
"AudienceTopicPage",
"AuthCheck",
"BulkContactImportResult",
"BulkDeleteResult",
"BulkListsAttachResult",
"BulkListsDetachResult",
"DkimInfo",
"DmarcValidationResult",
"DnsProvider",
Expand Down Expand Up @@ -172,6 +207,9 @@ def __init__(
self.projects = Projects(self._client)
"""Project management operations."""

self.audience = Audience(self._client)
"""Audience management — lists, contacts, topics, properties, segments."""

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

Expand Down
17 changes: 13 additions & 4 deletions src/lettr/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def __init__(
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "lettr-python/1.1.0",
"User-Agent": "lettr-python/1.2.0",
},
)

Expand Down Expand Up @@ -80,8 +80,17 @@ def post(self, path: str, *, json: dict[str, Any] | None = 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: dict[str, Any] | None = None) -> Any:
return self.request("DELETE", path, params=params)
def patch(self, path: str, *, json: dict[str, Any] | None = None) -> Any:
return self.request("PATCH", path, json=json)

def delete(
self,
path: str,
*,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
) -> Any:
return self.request("DELETE", path, params=params, json=json)

def get_no_auth(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
"""Send a GET request without the Authorization header.
Expand All @@ -98,7 +107,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.1.0",
"User-Agent": "lettr-python/1.2.0",
},
)
except httpx.HTTPError as exc:
Expand Down
202 changes: 201 additions & 1 deletion src/lettr/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,39 @@
from __future__ import annotations

from dataclasses import dataclass, fields
from typing import Any, TypeVar
from typing import Any, Final, TypeVar

T = TypeVar("T")


class _UnsetType:
"""Sentinel type for fields that were not provided.

Used by update methods to distinguish "field not provided" (the default)
from "set to null". Do not instantiate — use the singleton :data:`UNSET`.
"""

_instance: _UnsetType | None = None

def __new__(cls) -> _UnsetType:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

def __repr__(self) -> str:
return "UNSET"


UNSET: Final[_UnsetType] = _UnsetType()
"""Sentinel meaning "field not provided".

Pass to an update method's keyword to leave the field unchanged, or pass
``None`` to explicitly clear a nullable field. Importing::

from lettr import UNSET
"""


def _from_dict(cls: type[T], data: dict[str, Any]) -> T:
"""Build a dataclass instance from a dict, ignoring unknown keys.

Expand Down Expand Up @@ -448,3 +476,175 @@ class ProjectList:
per_page: int
current_page: int
last_page: int


# ---------------------------------------------------------------------------
# Audience types
# ---------------------------------------------------------------------------


@dataclass
class AudienceList:
"""An audience list."""

id: str
name: str
contacts_count: int


@dataclass
class AudienceListPage:
"""Paginated list of audience lists."""

lists: list[AudienceList]
total: int
per_page: int
current_page: int
last_page: int


@dataclass
class AudienceContactListRef:
"""A list reference embedded in a contact."""

id: str
name: str


@dataclass
class AudienceContactTopicRef:
"""A topic reference embedded in a contact."""

id: str
name: str


@dataclass
class AudienceContact:
"""An audience contact."""

id: str
email: str
status: str
properties: dict[str, str]
created_at: str
lists: list[AudienceContactListRef]
topics: list[AudienceContactTopicRef]


@dataclass
class AudienceContactPage:
"""Paginated list of audience contacts."""

contacts: list[AudienceContact]
total: int
per_page: int
current_page: int
last_page: int


@dataclass
class AudienceTopic:
"""An audience topic."""

id: str
name: str
default_subscription: str
visibility: str
contacts_count: int
description: str | None = None
created_at: str | None = None


@dataclass
class AudienceTopicPage:
"""Paginated list of audience topics."""

topics: list[AudienceTopic]
total: int
per_page: int
current_page: int
last_page: int


@dataclass
class AudienceProperty:
"""An audience custom property definition."""

id: str
name: str
type: str
created_at: str
fallback_value: str | None = None


@dataclass
class AudiencePropertyPage:
"""Paginated list of audience properties."""

properties: list[AudienceProperty]
total: int
per_page: int
current_page: int
last_page: int


@dataclass
class AudienceSegment:
"""An audience segment.

``condition_groups`` is kept as a list of raw dicts mirroring the
API shape (groups joined by OR, conditions within a group joined by AND).
"""

id: str
name: str
condition_groups: list[dict[str, Any]]
created_at: str
list_id: str | None = None
list_name: str | None = None
cached_contacts_count: int | None = None


@dataclass
class AudienceSegmentPage:
"""Paginated list of audience segments."""

segments: list[AudienceSegment]
total: int
per_page: int
current_page: int
last_page: int


@dataclass
class BulkDeleteResult:
"""Result of a bulk delete operation."""

deleted: int


@dataclass
class BulkContactImportResult:
"""Result of bulk-creating contacts."""

created: int
already_existed: int


@dataclass
class BulkListsAttachResult:
"""Result of bulk-attaching contacts to lists."""

attached: int
already_attached: int
total_pairs: int


@dataclass
class BulkListsDetachResult:
"""Result of bulk-detaching contacts from lists."""

detached: int
not_present: int
total_pairs: int
3 changes: 2 additions & 1 deletion src/lettr/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Lettr API resource modules."""

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

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