From 0ae8c916ca907804569038ef57ab314721e4d884 Mon Sep 17 00:00:00 2001 From: danhstevens Date: Mon, 2 Nov 2026 17:12:46 -0700 Subject: [PATCH 1/2] add suppressions.createBatch for POST /suppressions/batch; document that suppressions no longer expire --- README.md | 12 ++++++++ src/anypost/_async/resources.py | 9 ++++++ src/anypost/resources/suppressions.py | 21 ++++++++++++-- src/anypost/types/__init__.py | 6 ++++ src/anypost/types/suppression.py | 42 +++++++++++++++++++++++++-- tests/test_async.py | 10 +++++++ tests/test_resources.py | 24 +++++++++++++++ 7 files changed, 120 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1de9c42..1879360 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,18 @@ client.suppressions.create({ client.suppressions.delete("alice@example.com", "marketing") ``` +Import many at once with `create_batch` (up to 1,000 per call): + +```python +result = client.suppressions.create_batch([ + {"email": "alice@example.com", "topic": "marketing", "reason": "hard bounce"}, + {"email": "bob@example.com"}, +]) +print(result["imported"], result["already_suppressed"], result["invalid"]) +``` + +Addresses that are already suppressed are counted in `already_suppressed` and left unchanged, so a batch is safe to retry; invalid addresses come back in `invalid` instead of failing the call. Suppressions do not expire. + `get`, `list` (with `email_contains`, `topic`, `reason`, and `origin` filters), `list_for_email`, and `delete_for_email` round out the resource. See [Suppressions](https://anypost.com/docs/reference/suppressions) for scoping and the automatic-suppression rules for bounces and complaints. ## Webhooks diff --git a/src/anypost/_async/resources.py b/src/anypost/_async/resources.py index 6456e27..1ac7e5f 100644 --- a/src/anypost/_async/resources.py +++ b/src/anypost/_async/resources.py @@ -21,6 +21,8 @@ from ..types.identity import WhoamiResponse from ..types.suppression import ( Suppression, + SuppressionBatchItem, + SuppressionBatchResult, SuppressionCreateParams, SuppressionListParams, ) @@ -219,6 +221,13 @@ async def list( async def create(self, params: SuppressionCreateParams) -> Suppression: return await self._http.request("POST", "/suppressions", body=params) + async def create_batch( + self, suppressions: List[SuppressionBatchItem] + ) -> SuppressionBatchResult: + return await self._http.request( + "POST", "/suppressions/batch", body={"suppressions": suppressions} + ) + async def get(self, email: str, topic: str) -> Suppression: return await self._http.request( "GET", f"/suppressions/{quote(email, safe='')}/{quote(topic, safe='')}" diff --git a/src/anypost/resources/suppressions.py b/src/anypost/resources/suppressions.py index 19df122..6499b9f 100644 --- a/src/anypost/resources/suppressions.py +++ b/src/anypost/resources/suppressions.py @@ -9,6 +9,8 @@ from .._pagination import Page from ..types.suppression import ( Suppression, + SuppressionBatchItem, + SuppressionBatchResult, SuppressionCreateParams, SuppressionListParams, ) @@ -23,7 +25,7 @@ def __init__(self, http: HttpClient) -> None: def list( self, params: Optional[SuppressionListParams] = None ) -> Page[Suppression]: - """List the team's suppressions, newest-first. Expired rows are filtered out. + """List the team's suppressions, newest-first. Filter with ``email_contains``, ``topic``, ``reason``, and ``origin``. """ @@ -33,10 +35,25 @@ def create(self, params: SuppressionCreateParams) -> Suppression: """Add a manual suppression. Defaults to topic ``*`` (every topic). Raises ``validation_error`` if - an active entry for the same ``(email, topic)`` exists. + an entry for the same ``(email, topic)`` exists. """ return self._http.request("POST", "/suppressions", body=params) + def create_batch( + self, suppressions: List[SuppressionBatchItem] + ) -> SuppressionBatchResult: + """Add up to 1,000 suppressions in one request. + + Entries that are already suppressed are counted in ``already_suppressed`` + and left unchanged, so a batch is safe to retry. Invalid addresses come + back in ``invalid`` instead of failing the batch. Raises + ``validation_error`` (422) if the batch would pass the team's + suppression limit; nothing is added in that case. + """ + return self._http.request( + "POST", "/suppressions/batch", body={"suppressions": suppressions} + ) + def get(self, email: str, topic: str) -> Suppression: """Retrieve the suppression for an ``(email, topic)`` pair. diff --git a/src/anypost/types/__init__.py b/src/anypost/types/__init__.py index b8cf643..c44e049 100644 --- a/src/anypost/types/__init__.py +++ b/src/anypost/types/__init__.py @@ -44,6 +44,9 @@ from .identity import WhoamiLimits, WhoamiResponse from .suppression import ( Suppression, + SuppressionBatchInvalid, + SuppressionBatchItem, + SuppressionBatchResult, SuppressionCreateParams, SuppressionListParams, SuppressionOrigin, @@ -107,6 +110,9 @@ "WhoamiLimits", "WhoamiResponse", "Suppression", + "SuppressionBatchInvalid", + "SuppressionBatchItem", + "SuppressionBatchResult", "SuppressionCreateParams", "SuppressionListParams", "SuppressionOrigin", diff --git a/src/anypost/types/suppression.py b/src/anypost/types/suppression.py index 09b9c75..40d830f 100644 --- a/src/anypost/types/suppression.py +++ b/src/anypost/types/suppression.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional +from typing import List, Optional from typing_extensions import Literal, NotRequired, TypedDict @@ -36,7 +36,8 @@ class Suppression(TypedDict): note: Optional[str] #: When the suppression was first observed. suppressed_at: str - #: When it stops applying. ``None`` means never. Permanent bounces roll off after 90 days. + #: Always ``None``: suppressions do not expire. Kept for compatibility and + #: will be removed in a future major version. expires_at: Optional[str] created_at: str @@ -60,3 +61,40 @@ class SuppressionCreateParams(TypedDict): topic: NotRequired[str] #: Optional internal annotation, preserved across automatic re-suppressions. note: NotRequired[Optional[str]] + + +class SuppressionBatchItem(TypedDict): + """One entry in a ``suppressions.create_batch`` call.""" + + email: str + #: Topic to scope the suppression. Omit for ``*`` (every topic). + topic: NotRequired[str] + #: Free-form. ``permanent_bounce``, ``complaint``, ``unsubscribed``, and + #: ``manual`` are recognized, as are common aliases such as ``bounce``, + #: ``hard bounce``, ``spam report``, and ``opt-out`` (case and punctuation + #: ignored). Anything else records ``manual``. + reason: NotRequired[str] + #: Optional internal annotation (max 2000 characters). + note: NotRequired[Optional[str]] + + +class SuppressionBatchInvalid(TypedDict): + """An item in a batch whose email is not a valid address.""" + + #: Position in the request array, 0-based. + index: int + email: Optional[str] + + +class SuppressionBatchResult(TypedDict): + """Response from ``POST /suppressions/batch``. + + ``imported + already_suppressed + len(invalid)`` equals the items sent. + """ + + #: Suppressions this call created. + imported: int + #: Items whose address and topic were already suppressed, including repeats + #: within the batch. Left unchanged. + already_suppressed: int + invalid: List[SuppressionBatchInvalid] diff --git a/tests/test_async.py b/tests/test_async.py index e1afcfe..3d341dd 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -102,3 +102,13 @@ async def test_async_exhausts_retries(no_sleep: List[float]) -> None: await client.email.send({"from": "a@x.com", "to": ["b@y.com"], "text": "t"}) assert len(rec.requests) == 2 await client.aclose() + + +async def test_async_suppressions_create_batch() -> None: + body = {"imported": 1, "already_suppressed": 0, "invalid": []} + client, rec = make_async_client(lambda r: json_response(200, body)) + result = await client.suppressions.create_batch([{"email": "alice@example.com"}]) + assert (rec.last.method, rec.last.url.path) == ("POST", "/v1/suppressions/batch") + assert rec.body() == {"suppressions": [{"email": "alice@example.com"}]} + assert result["imported"] == 1 + await client.aclose() diff --git a/tests/test_resources.py b/tests/test_resources.py index 9e2a520..437ebaf 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -140,6 +140,30 @@ def test_suppressions_compound_and_email_paths() -> None: assert rec.last.url.path == "/v1/suppressions/user@example.com" +def test_suppressions_create_batch() -> None: + result_body = { + "imported": 1, + "already_suppressed": 0, + "invalid": [{"index": 1, "email": "not-an-address"}], + } + client, rec = make_client(lambda r: json_response(200, result_body)) + result = client.suppressions.create_batch( + [ + {"email": "alice@example.com", "topic": "marketing", "reason": "hard bounce"}, + {"email": "not-an-address"}, + ] + ) + assert (rec.last.method, rec.last.url.path) == ("POST", "/v1/suppressions/batch") + assert rec.body() == { + "suppressions": [ + {"email": "alice@example.com", "topic": "marketing", "reason": "hard bounce"}, + {"email": "not-an-address"}, + ] + } + assert result["imported"] == 1 + assert result["invalid"] == [{"index": 1, "email": "not-an-address"}] + + def test_whoami() -> None: body = { "team": {"id": "t_1", "name": "Acme"}, From 0763bd9d1ff5140d0f918107fd3bcab47a74c65a Mon Sep 17 00:00:00 2001 From: danhstevens Date: Mon, 2 Nov 2026 18:10:10 -0700 Subject: [PATCH 2/2] bump version to v1.5.0 --- src/anypost/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anypost/_version.py b/src/anypost/_version.py index 8d007c9..7787173 100644 --- a/src/anypost/_version.py +++ b/src/anypost/_version.py @@ -1,3 +1,3 @@ """Kept in sync with the ``version`` field in pyproject.toml.""" -__version__ = "1.4.0" +__version__ = "1.5.0"