diff --git a/CHANGELOG.md b/CHANGELOG.md index da8d322..753e988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Covers the reworked bulk contact import (TPL-2105) and the duplicate-create fix. +Everything here is additive — code written against 1.4.0 keeps working and sends +the exact same payloads. + +### Added +- **Per-contact bulk create.** `client.audience.contacts.bulk_create()` accepts a + second request shape where each contact carries its own properties, lists and + topic subscriptions, alongside the original flat `emails` list: + + ```python + client.audience.contacts.bulk_create( + contacts=[ + BulkContactRow(email="cara@example.com", properties={"plan": "pro"}), + BulkContactRow( + email="dan@example.com", + topics=[TopicSubscription.opt_out("01h-promos")], + ), + ], + list_ids=["01h-everyone"], + ) + ``` + + `emails` is now optional, so exactly one of `emails`/`contacts` must be given — + an empty call raises `ValueError` instead of being sent to the API. +- New request types `BulkContactRow` (`email`, `properties`, `list_ids`, + `topics`) and `TopicSubscription` (`id` + state, with the + `TopicSubscription.opt_in()` / `.opt_out()` constructors), plus the + `TopicSubscriptionState` literal (`"opt_in"` / `"opt_out"`). + + `TopicSubscriptionState` is what a request should *do* with a topic, and is + deliberately separate from a topic's `default_subscription`, which describes + how the topic behaves for a contact that says nothing. An `opt_out` on a topic + whose default is opt-out suppresses the auto-subscription in the same request + instead of needing a second call. +- **Batch-wide `list_ids` and `topics`,** plus `update_existing`, on + `bulk_create()`. Batch-wide lists and topics are unioned into every row; a + row-level property key or `opt_out` wins over the batch-wide value. + `update_existing=True` merges properties (submitted keys overwrite, absent keys + are preserved) and allows dropping a subscription. It is only sent when `True`, + so legacy payloads stay byte-identical. +- **Bulk create now reports what happened per row.** `BulkContactImportResult` + gains `updated`, `error_count`, `errors` (`BulkContactError` — `index`, + `email`, `error_code`, `error`) and `contacts` (`BulkContactRef` — `id`, + `email`, `created`), plus the `has_errors` and `contact_ids` properties and + `id_for(email)`. `created` and `already_existed` keep their exact meaning, and + the new fields default when the API omits them, so the result also parses a + pre-TPL-2105 response. + + A bulk create can **partially succeed**: rows that fail validation are skipped + and returned in `errors` while the rest of the batch commits, and the call + still returns HTTP 201. Check `result.has_errors` — a call that does not raise + does not mean every row landed. + + Note that `already_existed` and `updated` overlap by design. They answer + different questions ("was the address already in the audience?" vs "did this + request change the contact?"), so they do not sum to the row count: a contact + that already existed and got attached to a list is counted in both. +- `BulkContactErrorCode` literal (`missing_email`, `invalid_email`, + `invalid_property_value`, `unknown_property_key`, `unknown_list`, + `unknown_topic`, `invalid_topic_subscription`). `BulkContactError.error_code` + is typed as a union with `str`, so a code added server-side still parses. +- **Bulk topic subscribe/unsubscribe** — two methods on + `client.audience.contacts`, mirroring the existing + `bulk_attach_lists()` / `bulk_detach_lists()` pair: + - `bulk_subscribe_topics(contact_ids=..., topic_ids=...)` — + `POST /audience/contacts/topics/bulk`, returns `BulkTopicsSubscribeResult` + (`subscribed`, `already_subscribed`, `total_pairs`). + - `bulk_unsubscribe_topics(contact_ids=..., topic_ids=...)` — + `DELETE /audience/contacts/topics/bulk` with a request body, returns + `BulkTopicsUnsubscribeResult` (`unsubscribed`, `total_pairs`). Pairs that do + not exist are ignored. + + Both process every `contact_ids` × `topic_ids` combination (up to 1000 × 50). + Feed them `result.contact_ids` from a bulk create — no id lookup needed. +- `ContactAlreadyExistsError` — raised by `client.audience.contacts.create()` + when the email is already in the team's audience. It carries the colliding + `.email`. This is a client-correctable condition, **not** an outage: do not + retry it; update the existing contact with `update()`, or use + `bulk_create(update_existing=True)`. + +### Changed +- Creating a contact whose email already exists now raises + `ContactAlreadyExistsError` (HTTP 409, `resource_already_exists`). The API + previously let this escape as HTTP 500 with the misleading `send_error` code, + which arrived as a `ServerError`. **If your retry policy retries 5xx, duplicate + creates are no longer retried** — which was pointless anyway. Any error mapping + or docs of yours that name `send_error` for this endpoint should be corrected. + `ContactAlreadyExistsError` subclasses `ConflictError`, so existing + `except ConflictError` / `except LettrError` handlers catch it unchanged, and a + 409 with any other error code stays a plain `ConflictError`. + ## [1.4.0] - 2026-05-28 ### Added diff --git a/src/lettr/__init__.py b/src/lettr/__init__.py index 74e7e3d..e913834 100644 --- a/src/lettr/__init__.py +++ b/src/lettr/__init__.py @@ -21,6 +21,7 @@ AuthenticationError, BadRequestError, ConflictError, + ContactAlreadyExistsError, ForbiddenError, LettrError, NotFoundError, @@ -44,10 +45,16 @@ AudienceTopic, AudienceTopicPage, AuthCheck, + BulkContactError, + BulkContactErrorCode, BulkContactImportResult, + BulkContactRef, + BulkContactRow, BulkDeleteResult, BulkListsAttachResult, BulkListsDetachResult, + BulkTopicsSubscribeResult, + BulkTopicsUnsubscribeResult, Campaign, CampaignDetail, CampaignEvent, @@ -79,6 +86,8 @@ TemplateHtml, TemplateList, TemplateMergeTags, + TopicSubscription, + TopicSubscriptionState, UserAgentParsed, Webhook, ) @@ -95,6 +104,7 @@ "AuthenticationError", "BadRequestError", "ConflictError", + "ContactAlreadyExistsError", "ForbiddenError", "NotFoundError", "RateLimitError", @@ -117,10 +127,16 @@ "AudienceTopic", "AudienceTopicPage", "AuthCheck", + "BulkContactError", + "BulkContactErrorCode", "BulkContactImportResult", + "BulkContactRef", + "BulkContactRow", "BulkDeleteResult", "BulkListsAttachResult", "BulkListsDetachResult", + "BulkTopicsSubscribeResult", + "BulkTopicsUnsubscribeResult", "Campaign", "CampaignDetail", "CampaignEvent", @@ -152,6 +168,8 @@ "TemplateHtml", "TemplateList", "TemplateMergeTags", + "TopicSubscription", + "TopicSubscriptionState", "UserAgentParsed", "Webhook", ] diff --git a/src/lettr/_exceptions.py b/src/lettr/_exceptions.py index 588ba27..1272e50 100644 --- a/src/lettr/_exceptions.py +++ b/src/lettr/_exceptions.py @@ -61,6 +61,33 @@ def __init__(self, message: str, error_code: str | None = None) -> None: super().__init__(message) +class ContactAlreadyExistsError(ConflictError): + """Raised when creating a contact whose email is already in the audience. + + HTTP 409 with ``error_code="resource_already_exists"`` on + ``POST /audience/contacts``. + + This is a client-correctable condition, not an outage — **do not retry it.** + Update the existing contact with ``client.audience.contacts.update()``, or + use ``client.audience.contacts.bulk_create(..., update_existing=True)``. + + Older API versions surfaced this as an HTTP 500 with the misleading + ``send_error`` code, which arrived as a :class:`ServerError`. Subclassing + :class:`ConflictError` keeps existing ``except ConflictError`` and + ``except LettrError`` handlers working unchanged. + """ + + def __init__( + self, + message: str, + error_code: str | None = None, + email: str | None = None, + ) -> None: + self.email = email + """The address that collided, when the SDK knows it.""" + super().__init__(message, error_code) + + class BadRequestError(LettrError): """Raised for client-side errors (400).""" diff --git a/src/lettr/_types.py b/src/lettr/_types.py index f8ab011..127ea14 100644 --- a/src/lettr/_types.py +++ b/src/lettr/_types.py @@ -2,8 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass, fields -from typing import Any, Final, TypeVar +from dataclasses import dataclass, field, fields +from typing import Any, Final, Literal, TypeVar T = TypeVar("T") @@ -640,12 +640,198 @@ class BulkDeleteResult: deleted: int +TopicSubscriptionState = Literal["opt_in", "opt_out"] +"""What a write request should *do* with a topic. + +Distinct from a topic's ``default_subscription``, which describes how the topic +behaves for a contact that says nothing. ``"opt_out"`` here also cancels the +auto-subscription a topic with ``default_subscription="opt_out"`` would +otherwise give a newly created contact, so a create and an unsubscribe fit in +one request. +""" + +BulkContactErrorCode = Literal[ + "missing_email", + "invalid_email", + "invalid_property_value", + "unknown_property_key", + "unknown_list", + "unknown_topic", + "invalid_topic_subscription", +] +"""Reason a single row was skipped during a bulk create. + +Per-row codes reported inside a ``201`` body — not the top-level ``error_code`` +of a failed request. +""" + + +@dataclass +class TopicSubscription: + """A topic and the subscription state to apply to it. + + Used batch-wide on :meth:`~lettr.resources.audience.AudienceContacts.bulk_create` + and per row on :class:`BulkContactRow`. A row-level ``opt_out`` wins over a + batch-level ``opt_in`` for that contact. + + Build them with the constructors for readability at the call site:: + + TopicSubscription.opt_in("01h-newsletter") + TopicSubscription.opt_out("01h-promos") + """ + + id: str + subscription: TopicSubscriptionState = "opt_in" + + @classmethod + def opt_in(cls, topic_id: str) -> TopicSubscription: + """Subscribe the contact to the topic.""" + return cls(id=topic_id, subscription="opt_in") + + @classmethod + def opt_out(cls, topic_id: str) -> TopicSubscription: + """Suppress the topic for the contact. + + Including a topic that would otherwise auto-subscribe newly created + contacts. + """ + return cls(id=topic_id, subscription="opt_out") + + def to_payload(self) -> dict[str, Any]: + return {"id": self.id, "subscription": self.subscription} + + +@dataclass +class BulkContactRow: + """One contact in a bulk create payload. + + ``list_ids`` and ``topics`` here are applied **on top of** the batch-wide + ones passed to ``bulk_create()``; a ``properties`` key here overrides the + batch-wide value for the same key. + + A row that fails validation is skipped rather than failing the request — it + comes back in :attr:`BulkContactImportResult.errors`. + """ + + email: str + properties: dict[str, str] | None = None + """Each key must match a property defined for the team.""" + + list_ids: list[str] | None = None + topics: list[TopicSubscription] | None = None + + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = {"email": self.email} + if self.properties is not None: + payload["properties"] = self.properties + if self.list_ids is not None: + payload["list_ids"] = list(self.list_ids) + if self.topics is not None: + payload["topics"] = [topic.to_payload() for topic in self.topics] + return payload + + +@dataclass +class BulkContactError: + """A row that was skipped during a bulk create.""" + + index: int + """Zero-based position of the row in the submitted sequence.""" + + email: str | None + error_code: BulkContactErrorCode | str + """Typed as a union so a code added server-side survives as a plain string.""" + + error: str + + +@dataclass +class BulkContactRef: + """Identity of a contact that exists after a bulk create. + + Lets a caller chain into the bulk list and topic endpoints without a + follow-up lookup. + """ + + id: str + email: str + created: bool + """``True`` when this request created the contact, ``False`` when it already existed.""" + + @dataclass class BulkContactImportResult: - """Result of bulk-creating contacts.""" + """Result of bulk-creating contacts. + + A bulk create can **partially succeed**: rows that fail validation are + skipped and reported in :attr:`errors` while the rest of the batch is + written, and the call still returns HTTP 201. A call that does not raise + therefore does not mean every row landed — check :attr:`has_errors`. + + :attr:`already_existed` and :attr:`updated` overlap by design. They answer + different questions ("was the address already in the audience?" vs "did this + request change the contact?"), so they do not sum to the row count: a + contact that already existed and got attached to a list is counted in both. + """ created: int already_existed: int + updated: int = 0 + """Existing contacts this request changed — properties merged, a list or + topic attached, or a subscription dropped.""" + + error_count: int = 0 + """Number of skipped rows.""" + + errors: list[BulkContactError] = field(default_factory=list) + contacts: list[BulkContactRef] = field(default_factory=list) + """Every contact that exists after the request, in submission order.""" + + @property + def has_errors(self) -> bool: + """Whether any row was skipped. + + Always check this — a bulk create reports partial failures in the body, + not in the HTTP status. + """ + return bool(self.errors) + + @property + def contact_ids(self) -> list[str]: + """Ids of every contact that exists after the request, in submission order. + + Ready to feed into ``bulk_attach_lists()`` or ``bulk_subscribe_topics()``. + """ + return [contact.id for contact in self.contacts] + + def id_for(self, email: str) -> str | None: + """Look up the id for a submitted address. + + Matching is case-insensitive because the API normalizes addresses + before storing them. + """ + needle = email.strip().lower() + for contact in self.contacts: + if contact.email.lower() == needle: + return contact.id + return None + + +@dataclass +class BulkTopicsSubscribeResult: + """Result of bulk-subscribing contacts to topics.""" + + subscribed: int + already_subscribed: int + total_pairs: int + + +@dataclass +class BulkTopicsUnsubscribeResult: + """Result of bulk-unsubscribing contacts from topics.""" + + unsubscribed: int + total_pairs: int @dataclass diff --git a/src/lettr/resources/audience.py b/src/lettr/resources/audience.py index 4b5931d..c6742e9 100644 --- a/src/lettr/resources/audience.py +++ b/src/lettr/resources/audience.py @@ -6,7 +6,7 @@ from typing import Any from .._client import ApiClient -from .._exceptions import LettrError +from .._exceptions import ConflictError, ContactAlreadyExistsError, LettrError from .._types import ( UNSET, AudienceContact, @@ -21,13 +21,23 @@ AudienceSegmentPage, AudienceTopic, AudienceTopicPage, + BulkContactError, BulkContactImportResult, + BulkContactRef, + BulkContactRow, BulkDeleteResult, BulkListsAttachResult, BulkListsDetachResult, + BulkTopicsSubscribeResult, + BulkTopicsUnsubscribeResult, + TopicSubscription, _UnsetType, ) +# The only documented 409 on POST /audience/contacts is a duplicate email. Any +# other conflict code the API grows later stays a plain ConflictError. +_RESOURCE_ALREADY_EXISTS = "resource_already_exists" + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -101,6 +111,38 @@ def _parse_property(d: dict[str, Any]) -> AudienceProperty: ) +def _parse_bulk_import(data: dict[str, Any]) -> BulkContactImportResult: + """Parse a bulk-create body. + + ``updated``, ``error_count``, ``errors`` and ``contacts`` arrived with + TPL-2105; defaulting them keeps ``result.has_errors`` safe against an API + deployment that predates the change. + """ + return BulkContactImportResult( + created=data["created"], + already_existed=data["already_existed"], + updated=data.get("updated", 0), + error_count=data.get("error_count", 0), + errors=[ + BulkContactError( + index=item["index"], + email=item.get("email"), + error_code=item["error_code"], + error=item["error"], + ) + for item in data.get("errors") or [] + ], + contacts=[ + BulkContactRef( + id=item["id"], + email=item["email"], + created=item["created"], + ) + for item in data.get("contacts") or [] + ], + ) + + def _parse_segment(d: dict[str, Any]) -> AudienceSegment: return AudienceSegment( id=d["id"], @@ -267,6 +309,12 @@ def create( confirmation email. See the API reference for the expected shape (``from``, ``subject``, ``template_slug``, ``redirect_url``, and optional ``from_name``). + + Raises: + ContactAlreadyExistsError: The email is already in the team's + audience. A subclass of ``ConflictError``, so existing handlers + keep catching it. Do not retry — update the existing contact + instead, or use ``bulk_create(update_existing=True)``. """ payload: dict[str, Any] = {"email": email} if list_id is not None: @@ -275,7 +323,18 @@ def create( 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) + + try: + body = self._client.post("/audience/contacts", json=payload) + except ConflictError as exc: + if exc.error_code in (None, _RESOURCE_ALREADY_EXISTS): + raise ContactAlreadyExistsError( + message=exc.message, + error_code=exc.error_code, + email=email, + ) from exc + raise + return _parse_contact(body["data"]) def update( @@ -304,22 +363,89 @@ def delete(self, contact_id: str) -> None: def bulk_create( self, *, - emails: builtins.list[str], + emails: builtins.list[str] | None = None, list_id: str | None = None, properties: dict[str, str] | None = None, + contacts: builtins.list[BulkContactRow] | None = None, + list_ids: builtins.list[str] | None = None, + topics: builtins.list[TopicSubscription] | None = None, + update_existing: bool = False, ) -> BulkContactImportResult: - """Bulk-create up to 1000 contacts.""" - payload: dict[str, Any] = {"emails": emails} + """Bulk-create up to 1000 contacts. + + Two shapes are supported, and exactly one of them must be filled in: + + - ``emails`` — a flat list of addresses that all share ``list_id`` / + ``list_ids``, ``properties`` and ``topics``. The original shape, + unchanged:: + + client.audience.contacts.bulk_create( + emails=["a@example.com", "b@example.com"], + list_id="01h-everyone", + ) + + - ``contacts`` — one :class:`~lettr.BulkContactRow` per contact, each + with its own properties, lists and topic subscriptions:: + + client.audience.contacts.bulk_create( + contacts=[ + BulkContactRow(email="cara@example.com", properties={"plan": "pro"}), + BulkContactRow( + email="dan@example.com", + topics=[TopicSubscription.opt_out("01h-promos")], + ), + ], + list_ids=["01h-everyone"], + ) + + Batch-wide ``list_ids`` and ``topics`` are unioned into every row; a + row-level property key or ``opt_out`` wins over the batch-wide value. + + Args: + emails: 1–1000 addresses. Alternative to ``contacts``. + list_id: Single batch-wide list. Folded into ``list_ids`` server-side. + properties: Applied to every contact in the batch; a row's own key wins. + contacts: 1–1000 rows. Alternative to ``emails``. + list_ids: Max 50 batch-wide lists. + topics: Max 50 batch-wide topic subscriptions. + update_existing: When ``True``, existing contacts have their + properties merged (submitted keys overwrite, absent keys are + preserved) and ``opt_out`` entries applied. When ``False`` (the + default) existing contacts keep their properties but are still + attached to the requested lists. + + Returns: + A :class:`~lettr.BulkContactImportResult`. Rows that fail validation + are skipped rather than failing the request: the call still returns + HTTP 201 and reports them in ``errors``. Check ``result.has_errors`` + — a call that does not raise does not mean every row landed. + + Raises: + ValueError: Neither ``emails`` nor ``contacts`` was provided. + """ + if not emails and not contacts: + raise ValueError("bulk_create() needs at least one entry in either emails or contacts.") + + payload: dict[str, Any] = {} + if emails: + payload["emails"] = list(emails) if list_id is not None: payload["list_id"] = list_id if properties is not None: payload["properties"] = properties + if contacts is not None: + payload["contacts"] = [row.to_payload() for row in contacts] + if list_ids is not None: + payload["list_ids"] = list(list_ids) + if topics is not None: + payload["topics"] = [topic.to_payload() for topic in topics] + # Omitted when False so a legacy payload stays byte-identical; the API + # defaults it to False anyway. + if update_existing: + payload["update_existing"] = True + body = self._client.post("/audience/contacts/bulk", json=payload) - data = body["data"] - return BulkContactImportResult( - created=data["created"], - already_existed=data["already_existed"], - ) + return _parse_bulk_import(body["data"]) # -- list memberships --------------------------------------------------- @@ -383,6 +509,56 @@ 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}") + def bulk_subscribe_topics( + self, + *, + contact_ids: builtins.list[str], + topic_ids: builtins.list[str], + ) -> BulkTopicsSubscribeResult: + """Subscribe all ``contact_ids`` × ``topic_ids`` pairs (up to 1000 × 50). + + Feed it ``result.contact_ids`` from a ``bulk_create()`` — no id lookup + needed. + """ + body = _require_body( + self._client.post( + "/audience/contacts/topics/bulk", + json={"contact_ids": contact_ids, "topic_ids": topic_ids}, + ), + "POST /audience/contacts/topics/bulk", + ) + data = body["data"] + return BulkTopicsSubscribeResult( + subscribed=data["subscribed"], + already_subscribed=data["already_subscribed"], + total_pairs=data["total_pairs"], + ) + + def bulk_unsubscribe_topics( + self, + *, + contact_ids: builtins.list[str], + topic_ids: builtins.list[str], + ) -> BulkTopicsUnsubscribeResult: + """Unsubscribe all ``contact_ids`` × ``topic_ids`` pairs. + + Pairs that do not exist are ignored. Note this is a ``DELETE`` carrying + a request body — ``httpx`` handles that, as it already does for + ``bulk_detach_lists()``. + """ + body = _require_body( + self._client.delete( + "/audience/contacts/topics/bulk", + json={"contact_ids": contact_ids, "topic_ids": topic_ids}, + ), + "DELETE /audience/contacts/topics/bulk", + ) + data = body["data"] + return BulkTopicsUnsubscribeResult( + unsubscribed=data["unsubscribed"], + total_pairs=data["total_pairs"], + ) + # --------------------------------------------------------------------------- # Topics diff --git a/tests/test_audience.py b/tests/test_audience.py index 36151f0..6c66606 100644 --- a/tests/test_audience.py +++ b/tests/test_audience.py @@ -6,17 +6,22 @@ import pytest -from lettr._exceptions import LettrError +from lettr._exceptions import ConflictError, ContactAlreadyExistsError, LettrError from lettr._types import ( AudienceContact, AudienceList, AudienceProperty, AudienceSegment, AudienceTopic, + BulkContactError, BulkContactImportResult, + BulkContactRow, BulkDeleteResult, BulkListsAttachResult, BulkListsDetachResult, + BulkTopicsSubscribeResult, + BulkTopicsUnsubscribeResult, + TopicSubscription, ) from lettr.resources.audience import ( Audience, @@ -293,6 +298,156 @@ def test_bulk_create(self, contacts: AudienceContacts, mock_client: MagicMock) - payload = mock_client.post.call_args.kwargs["json"] assert payload["emails"][0] == "a@example.com" assert payload["list_id"] == "list_1" + # The pre-TPL-2105 payload must go out byte-identical — no `contacts` + # key, and no `update_existing` unless the caller asked for it. + assert set(payload) == {"emails", "list_id"} + + def test_bulk_create_defaults_missing_tpl_2105_fields( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + # An API deployment older than TPL-2105 answers with just the two + # counters. `has_errors` must still be readable. + mock_client.post.return_value = {"data": {"created": 2, "already_existed": 1}} + result = contacts.bulk_create(emails=["a@example.com"]) + assert result.updated == 0 + assert result.error_count == 0 + assert result.errors == [] + assert result.contacts == [] + assert result.has_errors is False + assert result.contact_ids == [] + + def test_bulk_create_with_rows( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = { + "data": { + "created": 2, + "already_existed": 0, + "updated": 0, + "error_count": 0, + "errors": [], + "contacts": [ + {"id": "c1", "email": "cara@example.com", "created": True}, + {"id": "c2", "email": "dan@example.com", "created": True}, + ], + } + } + result = contacts.bulk_create( + contacts=[ + BulkContactRow( + email="cara@example.com", + properties={"plan": "pro"}, + list_ids=["list_vip"], + ), + # Row-level opt_out must beat the batch-wide opt_in below. + BulkContactRow( + email="dan@example.com", + topics=[TopicSubscription.opt_out("topic_promos")], + ), + ], + list_ids=["list_everyone"], + topics=[TopicSubscription.opt_in("topic_promos")], + properties={"source": "spring-campaign"}, + update_existing=True, + ) + + payload = mock_client.post.call_args.kwargs["json"] + assert "emails" not in payload + assert payload["contacts"] == [ + { + "email": "cara@example.com", + "properties": {"plan": "pro"}, + "list_ids": ["list_vip"], + }, + { + "email": "dan@example.com", + "topics": [{"id": "topic_promos", "subscription": "opt_out"}], + }, + ] + assert payload["list_ids"] == ["list_everyone"] + assert payload["topics"] == [{"id": "topic_promos", "subscription": "opt_in"}] + assert payload["update_existing"] is True + + # Ids come back in submission order, so no follow-up lookup is needed. + assert result.contact_ids == ["c1", "c2"] + # id_for() is case-insensitive: the API normalizes addresses. + assert result.id_for("CARA@example.com ") == "c1" + assert result.id_for("nobody@example.com") is None + + def test_bulk_create_reports_skipped_rows( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + # Partial success: HTTP 201 with `errors` populated. Nothing raises, + # even though one row never landed — that is the trap this pins down. + mock_client.post.return_value = { + "data": { + "created": 1, + "already_existed": 0, + "updated": 0, + "error_count": 1, + "errors": [ + { + "index": 1, + "email": "not-an-email", + "error_code": "invalid_email", + "error": "The email address is not valid.", + } + ], + "contacts": [{"id": "c1", "email": "cara@example.com", "created": True}], + } + } + result = contacts.bulk_create( + contacts=[ + BulkContactRow(email="cara@example.com"), + BulkContactRow(email="not-an-email"), + ] + ) + + assert result.has_errors is True + assert result.error_count == 1 + assert result.errors[0] == BulkContactError( + index=1, + email="not-an-email", + error_code="invalid_email", + error="The email address is not valid.", + ) + assert result.contact_ids == ["c1"] + + def test_bulk_create_requires_emails_or_contacts( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + with pytest.raises(ValueError, match="emails or contacts"): + contacts.bulk_create(list_id="list_1") + mock_client.post.assert_not_called() + + def test_create_duplicate_raises_contact_already_exists( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.post.side_effect = ConflictError( + message="A contact with the email jane@example.com already exists.", + error_code="resource_already_exists", + ) + + with pytest.raises(ContactAlreadyExistsError) as excinfo: + contacts.create(email="jane@example.com") + + # Subclasses ConflictError, so pre-existing handlers keep working. + assert isinstance(excinfo.value, ConflictError) + assert excinfo.value.email == "jane@example.com" + assert excinfo.value.error_code == "resource_already_exists" + + def test_create_other_conflict_stays_generic( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.post.side_effect = ConflictError( + message="Something else conflicted.", + error_code="some_future_code", + ) + + with pytest.raises(ConflictError) as excinfo: + contacts.create(email="jane@example.com") + + assert not isinstance(excinfo.value, ContactAlreadyExistsError) class TestMemberships: @@ -339,6 +494,43 @@ def test_bulk_detach_lists(self, contacts: AudienceContacts, mock_client: MagicM json={"contact_ids": ["c1", "c2"], "list_ids": ["l1", "l2"]}, ) + def test_bulk_subscribe_topics( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = { + "data": {"subscribed": 3, "already_subscribed": 1, "total_pairs": 4} + } + result = contacts.bulk_subscribe_topics(contact_ids=["c1", "c2"], topic_ids=["t1", "t2"]) + assert isinstance(result, BulkTopicsSubscribeResult) + assert result.subscribed == 3 + assert result.already_subscribed == 1 + # 2 contacts × 2 topics — the endpoint works over the cartesian product. + assert result.total_pairs == 4 + mock_client.post.assert_called_once_with( + "/audience/contacts/topics/bulk", + json={"contact_ids": ["c1", "c2"], "topic_ids": ["t1", "t2"]}, + ) + + def test_bulk_unsubscribe_topics( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.delete.return_value = {"data": {"unsubscribed": 2, "total_pairs": 4}} + result = contacts.bulk_unsubscribe_topics(contact_ids=["c1", "c2"], topic_ids=["t1", "t2"]) + assert isinstance(result, BulkTopicsUnsubscribeResult) + assert result.unsubscribed == 2 + # DELETE with a request body — the pairs to remove travel in the body. + mock_client.delete.assert_called_once_with( + "/audience/contacts/topics/bulk", + json={"contact_ids": ["c1", "c2"], "topic_ids": ["t1", "t2"]}, + ) + + def test_bulk_subscribe_topics_empty_body_raises( + self, contacts: AudienceContacts, mock_client: MagicMock + ) -> None: + mock_client.post.return_value = None + with pytest.raises(LettrError, match="Unexpected empty response"): + contacts.bulk_subscribe_topics(contact_ids=["c1"], topic_ids=["t1"]) + # --------------------------------------------------------------------------- # Topics