diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cdaa20..ae7e8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.`: + - `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 diff --git a/pyproject.toml b/pyproject.toml index 4cdb4d4..5e8103a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/lettr/__init__.py b/src/lettr/__init__.py index b3c8c0a..0751f4f 100644 --- a/src/lettr/__init__.py +++ b/src/lettr/__init__.py @@ -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, @@ -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 @@ -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", @@ -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. diff --git a/src/lettr/_client.py b/src/lettr/_client.py index 8017ce3..456cbeb 100644 --- a/src/lettr/_client.py +++ b/src/lettr/_client.py @@ -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", }, ) @@ -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. @@ -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: diff --git a/src/lettr/_types.py b/src/lettr/_types.py index d3a4ad2..ba3de80 100644 --- a/src/lettr/_types.py +++ b/src/lettr/_types.py @@ -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. @@ -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 diff --git a/src/lettr/resources/__init__.py b/src/lettr/resources/__init__.py index c03bb51..2b983c3 100644 --- a/src/lettr/resources/__init__.py +++ b/src/lettr/resources/__init__.py @@ -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"] diff --git a/src/lettr/resources/audience.py b/src/lettr/resources/audience.py new file mode 100644 index 0000000..79a2aa2 --- /dev/null +++ b/src/lettr/resources/audience.py @@ -0,0 +1,689 @@ +"""Audience management — lists, contacts, topics, properties, segments.""" + +from __future__ import annotations + +import builtins +from typing import Any + +from .._client import ApiClient +from .._exceptions import LettrError +from .._types import ( + UNSET, + AudienceContact, + AudienceContactListRef, + AudienceContactPage, + AudienceContactTopicRef, + AudienceList, + AudienceListPage, + AudienceProperty, + AudiencePropertyPage, + AudienceSegment, + AudienceSegmentPage, + AudienceTopic, + AudienceTopicPage, + BulkContactImportResult, + BulkDeleteResult, + BulkListsAttachResult, + BulkListsDetachResult, + _UnsetType, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _require_body(body: Any, endpoint: str) -> dict[str, Any]: + """Raise a clear error when a body-returning endpoint responds with no body. + + The HTTP layer returns ``None`` for ``204 No Content``. The audience bulk + endpoints always respond with ``200`` and a JSON body per the OpenAPI + spec, so a ``None`` here indicates a server-side regression — surface it + as a ``LettrError`` rather than letting it become ``TypeError`` further + down. + """ + if body is None: + raise LettrError(f"Unexpected empty response from {endpoint}") + assert isinstance(body, dict) + return body + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + + +def _parse_list(d: dict[str, Any]) -> AudienceList: + return AudienceList( + id=d["id"], + name=d["name"], + contacts_count=d["contacts_count"], + ) + + +def _parse_contact(d: dict[str, Any]) -> AudienceContact: + return AudienceContact( + id=d["id"], + email=d["email"], + status=d["status"], + properties=d.get("properties") or {}, + created_at=d["created_at"], + lists=[ + AudienceContactListRef(id=item["id"], name=item["name"]) + for item in d.get("lists") or [] + ], + topics=[ + AudienceContactTopicRef(id=item["id"], name=item["name"]) + for item in d.get("topics") or [] + ], + ) + + +def _parse_topic(d: dict[str, Any]) -> AudienceTopic: + return AudienceTopic( + id=d["id"], + name=d["name"], + default_subscription=d["default_subscription"], + visibility=d["visibility"], + contacts_count=d["contacts_count"], + description=d.get("description"), + created_at=d.get("created_at"), + ) + + +def _parse_property(d: dict[str, Any]) -> AudienceProperty: + return AudienceProperty( + id=d["id"], + name=d["name"], + type=d["type"], + created_at=d["created_at"], + fallback_value=d.get("fallback_value"), + ) + + +def _parse_segment(d: dict[str, Any]) -> AudienceSegment: + return AudienceSegment( + id=d["id"], + name=d["name"], + condition_groups=d.get("condition_groups") or [], + created_at=d["created_at"], + list_id=d.get("list_id"), + list_name=d.get("list_name"), + cached_contacts_count=d.get("cached_contacts_count"), + ) + + +# --------------------------------------------------------------------------- +# Lists +# --------------------------------------------------------------------------- + + +class AudienceLists: + """Operations for audience lists. + + Usage:: + + page = client.audience.lists.list() + new = client.audience.lists.create(name="VIP") + client.audience.lists.delete(new.id) + """ + + def __init__(self, client: ApiClient) -> None: + self._client = client + + def list( + self, + *, + per_page: int | None = None, + page: int | None = None, + ) -> AudienceListPage: + """List audience lists with pagination.""" + params: dict[str, Any] = {} + if per_page is not None: + params["per_page"] = per_page + if page is not None: + params["page"] = page + + body = self._client.get("/audience/lists", params=params) + data = body["data"] + pagination = data["pagination"] + return AudienceListPage( + lists=[_parse_list(item) for item in data["lists"]], + total=pagination["total"], + per_page=pagination["per_page"], + current_page=pagination["current_page"], + last_page=pagination["last_page"], + ) + + def get(self, list_id: str) -> AudienceList: + """Get a single audience list by ID.""" + body = self._client.get(f"/audience/lists/{list_id}") + return _parse_list(body["data"]) + + def create(self, *, name: str) -> AudienceList: + """Create a new audience list.""" + body = self._client.post("/audience/lists", json={"name": name}) + return _parse_list(body["data"]) + + def update(self, list_id: str, *, name: str | None = None) -> AudienceList: + """Update an audience list. Only provided fields are sent.""" + payload: dict[str, Any] = {} + if name is not None: + payload["name"] = name + body = self._client.patch(f"/audience/lists/{list_id}", json=payload) + return _parse_list(body["data"]) + + def delete(self, list_id: str) -> None: + """Delete an audience list.""" + self._client.delete(f"/audience/lists/{list_id}") + + def bulk_delete(self, *, list_ids: builtins.list[str]) -> BulkDeleteResult: + """Delete multiple audience lists (1–50 IDs).""" + body = _require_body( + self._client.delete( + "/audience/lists/bulk", + json={"list_ids": list_ids}, + ), + "DELETE /audience/lists/bulk", + ) + return BulkDeleteResult(deleted=body["data"]["deleted"]) + + +# --------------------------------------------------------------------------- +# Contacts +# --------------------------------------------------------------------------- + + +class AudienceContacts: + """Operations for audience contacts and their list/topic memberships. + + Usage:: + + contact = client.audience.contacts.create(email="jane@example.com") + client.audience.contacts.add_to_list(contact_id=contact.id, list_id="...") + client.audience.contacts.subscribe_to_topic(contact_id=contact.id, topic_id="...") + """ + + def __init__(self, client: ApiClient) -> None: + self._client = client + + def list( + self, + *, + per_page: int | None = None, + page: int | None = None, + search: str | None = None, + status: str | None = None, + list_id: str | None = None, + segment_id: str | None = None, + ) -> AudienceContactPage: + """List audience contacts with pagination and filters.""" + params: dict[str, Any] = {} + if per_page is not None: + params["per_page"] = per_page + if page is not None: + params["page"] = page + if search is not None: + params["search"] = search + if status is not None: + params["status"] = status + if list_id is not None: + params["list_id"] = list_id + if segment_id is not None: + params["segment_id"] = segment_id + + body = self._client.get("/audience/contacts", params=params) + data = body["data"] + pagination = data["pagination"] + return AudienceContactPage( + contacts=[_parse_contact(item) for item in data["contacts"]], + total=pagination["total"], + per_page=pagination["per_page"], + current_page=pagination["current_page"], + last_page=pagination["last_page"], + ) + + def get(self, contact_id: str) -> AudienceContact: + """Get a single contact by ID.""" + body = self._client.get(f"/audience/contacts/{contact_id}") + return _parse_contact(body["data"]) + + def create( + self, + *, + email: str, + list_id: str | None = None, + properties: dict[str, str] | None = None, + double_opt_in: dict[str, Any] | None = None, + ) -> AudienceContact: + """Create a single contact. + + Args: + email: Contact email address. + list_id: Optional list to add the contact to. + properties: Custom property values (string). + double_opt_in: Optional double opt-in config. When provided, the + contact is created in ``unverified`` status and receives a + confirmation email. See the API reference for the expected + shape (``from``, ``subject``, ``template_slug``, ``redirect_url``, + and optional ``from_name``). + """ + payload: dict[str, Any] = {"email": email} + if list_id is not None: + payload["list_id"] = list_id + if properties is not None: + payload["properties"] = properties + if double_opt_in is not None: + payload["double_opt_in"] = double_opt_in + body = self._client.post("/audience/contacts", json=payload) + return _parse_contact(body["data"]) + + def update( + self, + contact_id: str, + *, + email: str | None = None, + status: str | None = None, + properties: dict[str, str | None] | None = None, + ) -> AudienceContact: + """Update a contact. A property set to ``None`` is removed.""" + payload: dict[str, Any] = {} + if email is not None: + payload["email"] = email + if status is not None: + payload["status"] = status + if properties is not None: + payload["properties"] = properties + body = self._client.patch(f"/audience/contacts/{contact_id}", json=payload) + return _parse_contact(body["data"]) + + def delete(self, contact_id: str) -> None: + """Delete a contact.""" + self._client.delete(f"/audience/contacts/{contact_id}") + + def bulk_create( + self, + *, + emails: builtins.list[str], + list_id: str | None = None, + properties: dict[str, str] | None = None, + ) -> BulkContactImportResult: + """Bulk-create up to 1000 contacts.""" + payload: dict[str, Any] = {"emails": emails} + if list_id is not None: + payload["list_id"] = list_id + if properties is not None: + payload["properties"] = properties + body = self._client.post("/audience/contacts/bulk", json=payload) + data = body["data"] + return BulkContactImportResult( + created=data["created"], + already_existed=data["already_existed"], + ) + + # -- list memberships --------------------------------------------------- + + def add_to_list(self, *, contact_id: str, list_id: str) -> None: + """Attach a contact to a list (idempotent).""" + self._client.post(f"/audience/contacts/{contact_id}/lists/{list_id}") + + def remove_from_list(self, *, contact_id: str, list_id: str) -> None: + """Detach a contact from a list (idempotent).""" + self._client.delete(f"/audience/contacts/{contact_id}/lists/{list_id}") + + def bulk_attach_lists( + self, + *, + contact_ids: builtins.list[str], + list_ids: builtins.list[str], + ) -> BulkListsAttachResult: + """Attach all ``contact_ids`` × ``list_ids`` pairs.""" + body = _require_body( + self._client.post( + "/audience/contacts/lists/bulk", + json={"contact_ids": contact_ids, "list_ids": list_ids}, + ), + "POST /audience/contacts/lists/bulk", + ) + data = body["data"] + return BulkListsAttachResult( + attached=data["attached"], + already_attached=data["already_attached"], + total_pairs=data["total_pairs"], + ) + + def bulk_detach_lists( + self, + *, + contact_ids: builtins.list[str], + list_ids: builtins.list[str], + ) -> BulkListsDetachResult: + """Detach all ``contact_ids`` × ``list_ids`` pairs.""" + body = _require_body( + self._client.delete( + "/audience/contacts/lists/bulk", + json={"contact_ids": contact_ids, "list_ids": list_ids}, + ), + "DELETE /audience/contacts/lists/bulk", + ) + data = body["data"] + return BulkListsDetachResult( + detached=data["detached"], + not_present=data["not_present"], + total_pairs=data["total_pairs"], + ) + + # -- topic subscriptions ------------------------------------------------ + + def subscribe_to_topic(self, *, contact_id: str, topic_id: str) -> None: + """Subscribe a contact to a topic (idempotent).""" + self._client.post(f"/audience/contacts/{contact_id}/topics/{topic_id}") + + def unsubscribe_from_topic(self, *, contact_id: str, topic_id: str) -> None: + """Unsubscribe a contact from a topic (idempotent).""" + self._client.delete(f"/audience/contacts/{contact_id}/topics/{topic_id}") + + +# --------------------------------------------------------------------------- +# Topics +# --------------------------------------------------------------------------- + + +class AudienceTopics: + """Operations for audience topics.""" + + def __init__(self, client: ApiClient) -> None: + self._client = client + + def list( + self, + *, + per_page: int | None = None, + page: int | None = None, + ) -> AudienceTopicPage: + """List audience topics with pagination.""" + params: dict[str, Any] = {} + if per_page is not None: + params["per_page"] = per_page + if page is not None: + params["page"] = page + body = self._client.get("/audience/topics", params=params) + data = body["data"] + pagination = data["pagination"] + return AudienceTopicPage( + topics=[_parse_topic(item) for item in data["topics"]], + total=pagination["total"], + per_page=pagination["per_page"], + current_page=pagination["current_page"], + last_page=pagination["last_page"], + ) + + def get(self, topic_id: str) -> AudienceTopic: + """Get a single topic by ID.""" + body = self._client.get(f"/audience/topics/{topic_id}") + return _parse_topic(body["data"]) + + def create( + self, + *, + name: str, + description: str | None = None, + default_subscription: str | None = None, + visibility: str | None = None, + ) -> AudienceTopic: + """Create a new topic. + + Args: + name: Topic name. + description: Optional description. + default_subscription: ``"opt_in"`` or ``"opt_out"`` (immutable + after creation; defaults to ``opt_in`` on the server). + visibility: ``"private"`` or ``"public"`` (defaults to + ``private`` on the server). + """ + payload: dict[str, Any] = {"name": name} + if description is not None: + payload["description"] = description + if default_subscription is not None: + payload["default_subscription"] = default_subscription + if visibility is not None: + payload["visibility"] = visibility + body = self._client.post("/audience/topics", json=payload) + return _parse_topic(body["data"]) + + def update( + self, + topic_id: str, + *, + name: str | _UnsetType = UNSET, + description: str | None | _UnsetType = UNSET, + visibility: str | _UnsetType = UNSET, + ) -> AudienceTopic: + """Update a topic. ``default_subscription`` is immutable. + + Pass ``description=None`` to clear the description; omit the + argument to leave it unchanged. + """ + payload: dict[str, Any] = {} + if not isinstance(name, _UnsetType): + payload["name"] = name + if not isinstance(description, _UnsetType): + payload["description"] = description + if not isinstance(visibility, _UnsetType): + payload["visibility"] = visibility + body = self._client.patch(f"/audience/topics/{topic_id}", json=payload) + return _parse_topic(body["data"]) + + def delete(self, topic_id: str) -> None: + """Delete a topic.""" + self._client.delete(f"/audience/topics/{topic_id}") + + +# --------------------------------------------------------------------------- +# Properties +# --------------------------------------------------------------------------- + + +class AudienceProperties: + """Operations for audience custom property definitions.""" + + def __init__(self, client: ApiClient) -> None: + self._client = client + + def list( + self, + *, + per_page: int | None = None, + page: int | None = None, + ) -> AudiencePropertyPage: + """List audience properties with pagination.""" + params: dict[str, Any] = {} + if per_page is not None: + params["per_page"] = per_page + if page is not None: + params["page"] = page + body = self._client.get("/audience/properties", params=params) + data = body["data"] + pagination = data["pagination"] + return AudiencePropertyPage( + properties=[_parse_property(item) for item in data["properties"]], + total=pagination["total"], + per_page=pagination["per_page"], + current_page=pagination["current_page"], + last_page=pagination["last_page"], + ) + + def get(self, property_id: str) -> AudienceProperty: + """Get a single property by ID.""" + body = self._client.get(f"/audience/properties/{property_id}") + return _parse_property(body["data"]) + + def create( + self, + *, + name: str, + type: str, + fallback_value: str | None = None, + ) -> AudienceProperty: + """Create a property definition. + + Args: + name: Property name (lowercase letters, digits, underscores; + must start with a letter). + type: One of ``"string"``, ``"number"``, ``"boolean"``, + ``"date"``, ``"json"``. + fallback_value: Optional fallback used when a contact has no + value for this property. + """ + payload: dict[str, Any] = {"name": name, "type": type} + if fallback_value is not None: + payload["fallback_value"] = fallback_value + body = self._client.post("/audience/properties", json=payload) + return _parse_property(body["data"]) + + def update( + self, + property_id: str, + *, + fallback_value: str | None | _UnsetType = UNSET, + ) -> AudienceProperty: + """Update a property's ``fallback_value``. ``name`` and ``type`` are + immutable. + + Pass ``fallback_value=None`` to clear the fallback; omit the + argument to leave it unchanged. + """ + payload: dict[str, Any] = {} + if not isinstance(fallback_value, _UnsetType): + payload["fallback_value"] = fallback_value + body = self._client.patch( + f"/audience/properties/{property_id}", json=payload + ) + return _parse_property(body["data"]) + + def delete(self, property_id: str) -> None: + """Delete a property.""" + self._client.delete(f"/audience/properties/{property_id}") + + +# --------------------------------------------------------------------------- +# Segments +# --------------------------------------------------------------------------- + + +class AudienceSegments: + """Operations for audience segments.""" + + def __init__(self, client: ApiClient) -> None: + self._client = client + + def list( + self, + *, + per_page: int | None = None, + page: int | None = None, + list_id: str | None = None, + ) -> AudienceSegmentPage: + """List audience segments with pagination.""" + params: dict[str, Any] = {} + if per_page is not None: + params["per_page"] = per_page + if page is not None: + params["page"] = page + if list_id is not None: + params["list_id"] = list_id + body = self._client.get("/audience/segments", params=params) + data = body["data"] + pagination = data["pagination"] + return AudienceSegmentPage( + segments=[_parse_segment(item) for item in data["segments"]], + total=pagination["total"], + per_page=pagination["per_page"], + current_page=pagination["current_page"], + last_page=pagination["last_page"], + ) + + def get(self, segment_id: str) -> AudienceSegment: + """Get a single segment by ID.""" + body = self._client.get(f"/audience/segments/{segment_id}") + return _parse_segment(body["data"]) + + def create( + self, + *, + name: str, + conditions: dict[str, Any], + list_id: str | None = None, + ) -> AudienceSegment: + """Create a segment. + + Args: + name: Segment name. + conditions: Raw condition object matching the OpenAPI + ``SegmentConditionsInput`` shape, e.g.:: + + {"groups": [{"conditions": [ + {"field": "email", "operator": "contains", + "value": "@example.com"} + ]}]} + list_id: Optional list to restrict the segment to. + """ + payload: dict[str, Any] = {"name": name, "conditions": conditions} + if list_id is not None: + payload["list_id"] = list_id + body = self._client.post("/audience/segments", json=payload) + return _parse_segment(body["data"]) + + def update( + self, + segment_id: str, + *, + name: str | _UnsetType = UNSET, + conditions: dict[str, Any] | _UnsetType = UNSET, + list_id: str | None | _UnsetType = UNSET, + ) -> AudienceSegment: + """Update a segment. Only provided fields are sent. + + Pass ``list_id=None`` to clear the list restriction (segment applies + to all lists); omit the argument to leave it unchanged. + """ + payload: dict[str, Any] = {} + if not isinstance(name, _UnsetType): + payload["name"] = name + if not isinstance(conditions, _UnsetType): + payload["conditions"] = conditions + if not isinstance(list_id, _UnsetType): + payload["list_id"] = list_id + body = self._client.patch(f"/audience/segments/{segment_id}", json=payload) + return _parse_segment(body["data"]) + + def delete(self, segment_id: str) -> None: + """Delete a segment.""" + self._client.delete(f"/audience/segments/{segment_id}") + + +# --------------------------------------------------------------------------- +# Container +# --------------------------------------------------------------------------- + + +class Audience: + """Audience management — entry point for lists, contacts, topics, + properties, and segments. + + Usage:: + + client.audience.lists.list() + client.audience.contacts.create(email="jane@example.com") + client.audience.topics.list() + client.audience.properties.create(name="first_name", type="string") + client.audience.segments.create(name="Active", conditions={...}) + """ + + def __init__(self, client: ApiClient) -> None: + self._client = client + self.lists = AudienceLists(client) + self.contacts = AudienceContacts(client) + self.topics = AudienceTopics(client) + self.properties = AudienceProperties(client) + self.segments = AudienceSegments(client) diff --git a/tests/test_audience.py b/tests/test_audience.py new file mode 100644 index 0000000..058519c --- /dev/null +++ b/tests/test_audience.py @@ -0,0 +1,579 @@ +"""Tests for the Audience resource (lists, contacts, topics, properties, segments).""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from lettr._exceptions import LettrError +from lettr._types import ( + AudienceContact, + AudienceList, + AudienceProperty, + AudienceSegment, + AudienceTopic, + BulkContactImportResult, + BulkDeleteResult, + BulkListsAttachResult, + BulkListsDetachResult, +) +from lettr.resources.audience import ( + Audience, + AudienceContacts, + AudienceLists, + AudienceProperties, + AudienceSegments, + AudienceTopics, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def audience(mock_client: MagicMock) -> Audience: + return Audience(mock_client) + + +@pytest.fixture() +def lists(mock_client: MagicMock) -> AudienceLists: + return AudienceLists(mock_client) + + +@pytest.fixture() +def contacts(mock_client: MagicMock) -> AudienceContacts: + return AudienceContacts(mock_client) + + +@pytest.fixture() +def topics(mock_client: MagicMock) -> AudienceTopics: + return AudienceTopics(mock_client) + + +@pytest.fixture() +def properties(mock_client: MagicMock) -> AudienceProperties: + return AudienceProperties(mock_client) + + +@pytest.fixture() +def segments(mock_client: MagicMock) -> AudienceSegments: + return AudienceSegments(mock_client) + + +LIST_DATA = { + "id": "list_1", + "name": "VIP", + "contacts_count": 5, +} + +CONTACT_DATA = { + "id": "contact_1", + "email": "jane@example.com", + "status": "subscribed", + "properties": {"first_name": "Jane"}, + "created_at": "2025-01-01T00:00:00Z", + "lists": [{"id": "list_1", "name": "VIP"}], + "topics": [{"id": "topic_1", "name": "Updates"}], +} + +TOPIC_DATA = { + "id": "topic_1", + "name": "Updates", + "description": "Monthly newsletter", + "default_subscription": "opt_in", + "visibility": "public", + "contacts_count": 12, + "created_at": "2025-01-01T00:00:00Z", +} + +PROPERTY_DATA = { + "id": "prop_1", + "name": "first_name", + "type": "string", + "fallback_value": "Friend", + "created_at": "2025-01-01T00:00:00Z", +} + +SEGMENT_DATA = { + "id": "seg_1", + "name": "Active", + "list_id": None, + "list_name": None, + "condition_groups": [ + {"conditions": [{"field": "status", "operator": "equals", "value": "subscribed"}]} + ], + "cached_contacts_count": 3, + "created_at": "2025-01-01T00:00:00Z", +} + +PAGINATION = {"total": 1, "per_page": 25, "current_page": 1, "last_page": 1} + + +# --------------------------------------------------------------------------- +# Audience container +# --------------------------------------------------------------------------- + + +class TestAudienceContainer: + def test_sub_resources_wired(self, audience: Audience) -> None: + assert isinstance(audience.lists, AudienceLists) + assert isinstance(audience.contacts, AudienceContacts) + assert isinstance(audience.topics, AudienceTopics) + assert isinstance(audience.properties, AudienceProperties) + assert isinstance(audience.segments, AudienceSegments) + + +# --------------------------------------------------------------------------- +# Lists +# --------------------------------------------------------------------------- + + +class TestLists: + def test_list(self, lists: AudienceLists, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": {"lists": [LIST_DATA], "pagination": PAGINATION} + } + page = lists.list() + assert len(page.lists) == 1 + assert page.lists[0].name == "VIP" + assert page.total == 1 + mock_client.get.assert_called_once_with("/audience/lists", params={}) + + def test_list_forwards_pagination( + self, lists: AudienceLists, mock_client: MagicMock + ) -> None: + mock_client.get.return_value = { + "data": {"lists": [], "pagination": PAGINATION} + } + lists.list(per_page=10, page=2) + params = mock_client.get.call_args.kwargs["params"] + assert params == {"per_page": 10, "page": 2} + + def test_get(self, lists: AudienceLists, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": LIST_DATA} + result = lists.get("list_1") + assert isinstance(result, AudienceList) + assert result.id == "list_1" + mock_client.get.assert_called_once_with("/audience/lists/list_1") + + def test_create(self, lists: AudienceLists, mock_client: MagicMock) -> None: + mock_client.post.return_value = {"data": LIST_DATA} + result = lists.create(name="VIP") + assert result.name == "VIP" + mock_client.post.assert_called_once_with( + "/audience/lists", json={"name": "VIP"} + ) + + def test_update_partial(self, lists: AudienceLists, mock_client: MagicMock) -> None: + mock_client.patch.return_value = {"data": LIST_DATA} + lists.update("list_1", name="Renamed") + mock_client.patch.assert_called_once_with( + "/audience/lists/list_1", json={"name": "Renamed"} + ) + + def test_update_empty(self, lists: AudienceLists, mock_client: MagicMock) -> None: + mock_client.patch.return_value = {"data": LIST_DATA} + lists.update("list_1") + mock_client.patch.assert_called_once_with("/audience/lists/list_1", json={}) + + def test_delete(self, lists: AudienceLists, mock_client: MagicMock) -> None: + lists.delete("list_1") + mock_client.delete.assert_called_once_with("/audience/lists/list_1") + + def test_bulk_delete(self, lists: AudienceLists, mock_client: MagicMock) -> None: + mock_client.delete.return_value = {"data": {"deleted": 2}} + result = lists.bulk_delete(list_ids=["list_1", "list_2"]) + assert isinstance(result, BulkDeleteResult) + assert result.deleted == 2 + mock_client.delete.assert_called_once_with( + "/audience/lists/bulk", + json={"list_ids": ["list_1", "list_2"]}, + ) + + def test_bulk_delete_empty_body_raises( + self, lists: AudienceLists, mock_client: MagicMock + ) -> None: + """A None body (HTTP 204) becomes a clear LettrError, not TypeError.""" + mock_client.delete.return_value = None + with pytest.raises(LettrError, match="empty response"): + lists.bulk_delete(list_ids=["list_1"]) + + +# --------------------------------------------------------------------------- +# Contacts +# --------------------------------------------------------------------------- + + +class TestContacts: + def test_list(self, contacts: AudienceContacts, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": {"contacts": [CONTACT_DATA], "pagination": PAGINATION} + } + page = contacts.list() + assert len(page.contacts) == 1 + assert page.contacts[0].email == "jane@example.com" + assert page.contacts[0].lists[0].name == "VIP" + assert page.contacts[0].topics[0].id == "topic_1" + + def test_list_forwards_filters( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.get.return_value = { + "data": {"contacts": [], "pagination": PAGINATION} + } + contacts.list( + per_page=50, + page=3, + search="jane", + status="subscribed", + list_id="list_1", + segment_id="seg_1", + ) + params = mock_client.get.call_args.kwargs["params"] + assert params == { + "per_page": 50, + "page": 3, + "search": "jane", + "status": "subscribed", + "list_id": "list_1", + "segment_id": "seg_1", + } + + def test_get(self, contacts: AudienceContacts, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": CONTACT_DATA} + result = contacts.get("contact_1") + assert isinstance(result, AudienceContact) + mock_client.get.assert_called_once_with("/audience/contacts/contact_1") + + def test_create_minimal( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = {"data": CONTACT_DATA} + contacts.create(email="jane@example.com") + payload = mock_client.post.call_args.kwargs["json"] + assert payload == {"email": "jane@example.com"} + + def test_create_with_all_fields( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = {"data": CONTACT_DATA} + contacts.create( + email="jane@example.com", + list_id="list_1", + properties={"first_name": "Jane"}, + double_opt_in={ + "from": "no-reply@example.com", + "subject": "Confirm", + "template_slug": "doi", + "redirect_url": "https://example.com/ok", + }, + ) + payload = mock_client.post.call_args.kwargs["json"] + assert payload["list_id"] == "list_1" + assert payload["properties"] == {"first_name": "Jane"} + assert payload["double_opt_in"]["template_slug"] == "doi" + + def test_update_partial( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.patch.return_value = {"data": CONTACT_DATA} + contacts.update( + "contact_1", + status="unsubscribed", + properties={"country": "US", "first_name": None}, + ) + payload = mock_client.patch.call_args.kwargs["json"] + assert payload == { + "status": "unsubscribed", + "properties": {"country": "US", "first_name": None}, + } + assert "email" not in payload + + def test_delete(self, contacts: AudienceContacts, mock_client: MagicMock) -> None: + contacts.delete("contact_1") + mock_client.delete.assert_called_once_with("/audience/contacts/contact_1") + + def test_bulk_create( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = { + "data": {"created": 2, "already_existed": 1} + } + result = contacts.bulk_create( + emails=["a@example.com", "b@example.com", "c@example.com"], + list_id="list_1", + ) + assert isinstance(result, BulkContactImportResult) + assert result.created == 2 + assert result.already_existed == 1 + payload = mock_client.post.call_args.kwargs["json"] + assert payload["emails"][0] == "a@example.com" + assert payload["list_id"] == "list_1" + + +class TestMemberships: + def test_add_to_list( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + contacts.add_to_list(contact_id="contact_1", list_id="list_1") + mock_client.post.assert_called_once_with( + "/audience/contacts/contact_1/lists/list_1" + ) + + def test_remove_from_list( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + contacts.remove_from_list(contact_id="contact_1", list_id="list_1") + mock_client.delete.assert_called_once_with( + "/audience/contacts/contact_1/lists/list_1" + ) + + def test_subscribe_to_topic( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + contacts.subscribe_to_topic(contact_id="contact_1", topic_id="topic_1") + mock_client.post.assert_called_once_with( + "/audience/contacts/contact_1/topics/topic_1" + ) + + def test_unsubscribe_from_topic( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + contacts.unsubscribe_from_topic(contact_id="contact_1", topic_id="topic_1") + mock_client.delete.assert_called_once_with( + "/audience/contacts/contact_1/topics/topic_1" + ) + + def test_bulk_attach_lists( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = { + "data": {"attached": 3, "already_attached": 1, "total_pairs": 4} + } + result = contacts.bulk_attach_lists( + contact_ids=["c1", "c2"], list_ids=["l1", "l2"] + ) + assert isinstance(result, BulkListsAttachResult) + assert result.attached == 3 + assert result.total_pairs == 4 + mock_client.post.assert_called_once_with( + "/audience/contacts/lists/bulk", + json={"contact_ids": ["c1", "c2"], "list_ids": ["l1", "l2"]}, + ) + + def test_bulk_detach_lists( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.delete.return_value = { + "data": {"detached": 2, "not_present": 2, "total_pairs": 4} + } + result = contacts.bulk_detach_lists( + contact_ids=["c1", "c2"], list_ids=["l1", "l2"] + ) + assert isinstance(result, BulkListsDetachResult) + assert result.detached == 2 + mock_client.delete.assert_called_once_with( + "/audience/contacts/lists/bulk", + json={"contact_ids": ["c1", "c2"], "list_ids": ["l1", "l2"]}, + ) + + +# --------------------------------------------------------------------------- +# Topics +# --------------------------------------------------------------------------- + + +class TestTopics: + def test_list(self, topics: AudienceTopics, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": {"topics": [TOPIC_DATA], "pagination": PAGINATION} + } + page = topics.list() + assert page.topics[0].visibility == "public" + + def test_get(self, topics: AudienceTopics, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": TOPIC_DATA} + result = topics.get("topic_1") + assert isinstance(result, AudienceTopic) + mock_client.get.assert_called_once_with("/audience/topics/topic_1") + + def test_create(self, topics: AudienceTopics, mock_client: MagicMock) -> None: + mock_client.post.return_value = {"data": TOPIC_DATA} + topics.create( + name="Updates", + description="Monthly newsletter", + default_subscription="opt_in", + visibility="public", + ) + payload = mock_client.post.call_args.kwargs["json"] + assert payload == { + "name": "Updates", + "description": "Monthly newsletter", + "default_subscription": "opt_in", + "visibility": "public", + } + + def test_update_partial( + self, topics: AudienceTopics, mock_client: MagicMock + ) -> None: + mock_client.patch.return_value = {"data": TOPIC_DATA} + topics.update("topic_1", name="Renamed") + payload = mock_client.patch.call_args.kwargs["json"] + assert payload == {"name": "Renamed"} + + def test_update_clear_description( + self, topics: AudienceTopics, mock_client: MagicMock + ) -> None: + """description=None sends `"description": null` to clear the field.""" + mock_client.patch.return_value = {"data": TOPIC_DATA} + topics.update("topic_1", description=None) + payload = mock_client.patch.call_args.kwargs["json"] + assert payload == {"description": None} + + def test_update_empty(self, topics: AudienceTopics, mock_client: MagicMock) -> None: + """No args = empty payload (no field is touched, including description).""" + mock_client.patch.return_value = {"data": TOPIC_DATA} + topics.update("topic_1") + mock_client.patch.assert_called_once_with("/audience/topics/topic_1", json={}) + + def test_delete(self, topics: AudienceTopics, mock_client: MagicMock) -> None: + topics.delete("topic_1") + mock_client.delete.assert_called_once_with("/audience/topics/topic_1") + + +# --------------------------------------------------------------------------- +# Properties +# --------------------------------------------------------------------------- + + +class TestProperties: + def test_list(self, properties: AudienceProperties, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": {"properties": [PROPERTY_DATA], "pagination": PAGINATION} + } + page = properties.list() + assert page.properties[0].name == "first_name" + + def test_get(self, properties: AudienceProperties, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": PROPERTY_DATA} + result = properties.get("prop_1") + assert isinstance(result, AudienceProperty) + mock_client.get.assert_called_once_with("/audience/properties/prop_1") + + def test_create( + self, properties: AudienceProperties, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = {"data": PROPERTY_DATA} + properties.create(name="first_name", type="string", fallback_value="Friend") + payload = mock_client.post.call_args.kwargs["json"] + assert payload == { + "name": "first_name", + "type": "string", + "fallback_value": "Friend", + } + + def test_update( + self, properties: AudienceProperties, mock_client: MagicMock + ) -> None: + mock_client.patch.return_value = {"data": PROPERTY_DATA} + properties.update("prop_1", fallback_value="Guest") + mock_client.patch.assert_called_once_with( + "/audience/properties/prop_1", json={"fallback_value": "Guest"} + ) + + def test_update_clear_fallback( + self, properties: AudienceProperties, mock_client: MagicMock + ) -> None: + """fallback_value=None sends `"fallback_value": null`.""" + mock_client.patch.return_value = {"data": PROPERTY_DATA} + properties.update("prop_1", fallback_value=None) + payload = mock_client.patch.call_args.kwargs["json"] + assert payload == {"fallback_value": None} + + def test_update_empty( + self, properties: AudienceProperties, mock_client: MagicMock + ) -> None: + mock_client.patch.return_value = {"data": PROPERTY_DATA} + properties.update("prop_1") + mock_client.patch.assert_called_once_with( + "/audience/properties/prop_1", json={} + ) + + def test_delete( + self, properties: AudienceProperties, mock_client: MagicMock + ) -> None: + properties.delete("prop_1") + mock_client.delete.assert_called_once_with("/audience/properties/prop_1") + + +# --------------------------------------------------------------------------- +# Segments +# --------------------------------------------------------------------------- + + +class TestSegments: + def test_list(self, segments: AudienceSegments, mock_client: MagicMock) -> None: + mock_client.get.return_value = { + "data": {"segments": [SEGMENT_DATA], "pagination": PAGINATION} + } + page = segments.list(list_id="list_1") + assert page.segments[0].name == "Active" + params = mock_client.get.call_args.kwargs["params"] + assert params == {"list_id": "list_1"} + + def test_get(self, segments: AudienceSegments, mock_client: MagicMock) -> None: + mock_client.get.return_value = {"data": SEGMENT_DATA} + result = segments.get("seg_1") + assert isinstance(result, AudienceSegment) + assert result.condition_groups[0]["conditions"][0]["field"] == "status" + + def test_create(self, segments: AudienceSegments, mock_client: MagicMock) -> None: + mock_client.post.return_value = {"data": SEGMENT_DATA} + conditions = { + "groups": [ + { + "conditions": [ + {"field": "status", "operator": "equals", "value": "subscribed"} + ] + } + ] + } + segments.create(name="Active", conditions=conditions, list_id="list_1") + payload = mock_client.post.call_args.kwargs["json"] + assert payload == { + "name": "Active", + "conditions": conditions, + "list_id": "list_1", + } + + def test_update_partial( + self, segments: AudienceSegments, mock_client: MagicMock + ) -> None: + mock_client.patch.return_value = {"data": SEGMENT_DATA} + segments.update("seg_1", name="Renamed") + payload = mock_client.patch.call_args.kwargs["json"] + assert payload == {"name": "Renamed"} + + def test_update_clear_list_id( + self, segments: AudienceSegments, mock_client: MagicMock + ) -> None: + """list_id=None sends `"list_id": null` to drop the list restriction.""" + mock_client.patch.return_value = {"data": SEGMENT_DATA} + segments.update("seg_1", list_id=None) + payload = mock_client.patch.call_args.kwargs["json"] + assert payload == {"list_id": None} + + def test_update_empty( + self, segments: AudienceSegments, mock_client: MagicMock + ) -> None: + mock_client.patch.return_value = {"data": SEGMENT_DATA} + segments.update("seg_1") + mock_client.patch.assert_called_once_with( + "/audience/segments/seg_1", json={} + ) + + def test_delete(self, segments: AudienceSegments, mock_client: MagicMock) -> None: + segments.delete("seg_1") + mock_client.delete.assert_called_once_with("/audience/segments/seg_1")