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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/anypost/_async/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from ..types.identity import WhoamiResponse
from ..types.suppression import (
Suppression,
SuppressionBatchItem,
SuppressionBatchResult,
SuppressionCreateParams,
SuppressionListParams,
)
Expand Down Expand Up @@ -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='')}"
Expand Down
2 changes: 1 addition & 1 deletion src/anypost/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Kept in sync with the ``version`` field in pyproject.toml."""

__version__ = "1.4.0"
__version__ = "1.5.0"
21 changes: 19 additions & 2 deletions src/anypost/resources/suppressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from .._pagination import Page
from ..types.suppression import (
Suppression,
SuppressionBatchItem,
SuppressionBatchResult,
SuppressionCreateParams,
SuppressionListParams,
)
Expand All @@ -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``.
"""
Expand All @@ -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.

Expand Down
6 changes: 6 additions & 0 deletions src/anypost/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
from .identity import WhoamiLimits, WhoamiResponse
from .suppression import (
Suppression,
SuppressionBatchInvalid,
SuppressionBatchItem,
SuppressionBatchResult,
SuppressionCreateParams,
SuppressionListParams,
SuppressionOrigin,
Expand Down Expand Up @@ -107,6 +110,9 @@
"WhoamiLimits",
"WhoamiResponse",
"Suppression",
"SuppressionBatchInvalid",
"SuppressionBatchItem",
"SuppressionBatchResult",
"SuppressionCreateParams",
"SuppressionListParams",
"SuppressionOrigin",
Expand Down
42 changes: 40 additions & 2 deletions src/anypost/types/suppression.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import Optional
from typing import List, Optional

from typing_extensions import Literal, NotRequired, TypedDict

Expand Down Expand Up @@ -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

Expand All @@ -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]
10 changes: 10 additions & 0 deletions tests/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
24 changes: 24 additions & 0 deletions tests/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
Loading