diff --git a/examples/async/suppressions_async.py b/examples/async/suppressions_async.py new file mode 100644 index 0000000..55755a4 --- /dev/null +++ b/examples/async/suppressions_async.py @@ -0,0 +1,48 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + + +async def main() -> None: + add_params: resend.Suppressions.AddParams = { + "email": "blocked@example.com", + } + added = await resend.Suppressions.add_async(add_params) + print(f"Added suppression: {added['id']}") + + list_params: resend.Suppressions.ListParams = { + "origin": "bounce", + "limit": 10, + } + suppressions = await resend.Suppressions.list_async(list_params) + print(f"Has more: {suppressions['has_more']}") + for suppression in suppressions["data"]: + print(f"{suppression['email']} ({suppression['origin']})") + + found: resend.Suppression = await resend.Suppressions.get_async( + "blocked@example.com" + ) + print(f"Suppressed at: {found['created_at']}") + + batch_add_params: resend.Suppressions.Batch.AddParams = { + "emails": ["one@example.com", "two@example.com"], + } + batch_added = await resend.Suppressions.Batch.add_async(batch_add_params) + print(f"Batch added {len(batch_added['data'])} suppressions") + + batch_remove_params: resend.Suppressions.Batch.RemoveParams = { + "ids": [entry["id"] for entry in batch_added["data"]], + } + batch_removed = await resend.Suppressions.Batch.remove_async(batch_remove_params) + print(f"Batch removed {len(batch_removed['data'])} suppressions") + + removed = await resend.Suppressions.remove_async("blocked@example.com") + print(f"Removed suppression {removed['id']}: deleted={removed['deleted']}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/suppressions.py b/examples/suppressions.py new file mode 100644 index 0000000..a979b35 --- /dev/null +++ b/examples/suppressions.py @@ -0,0 +1,49 @@ +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + + +# Add: stop delivering to an address. +add_params: resend.Suppressions.AddParams = { + "email": "blocked@example.com", +} +added = resend.Suppressions.add(add_params) +print(f"Added suppression: {added['id']}") + +# List: page through suppressions, optionally filtered by origin. +list_params: resend.Suppressions.ListParams = { + "origin": "bounce", + "limit": 10, +} +suppressions = resend.Suppressions.list(list_params) +print(f"Has more: {suppressions['has_more']}") +for suppression in suppressions["data"]: + print( + f"{suppression['email']} ({suppression['origin']}) " + f"source_id={suppression['source_id']}" + ) + +# Get: look up a suppression by ID or by email address. +found: resend.Suppression = resend.Suppressions.get("blocked@example.com") +print(f"Suppressed at: {found['created_at']}") + +# Batch add: suppress up to 100 addresses in a single call. +batch_add_params: resend.Suppressions.Batch.AddParams = { + "emails": ["one@example.com", "two@example.com"], +} +batch_added = resend.Suppressions.Batch.add(batch_add_params) +print(f"Batch added {len(batch_added['data'])} suppressions") + +# Batch remove: pass either emails or ids, never both. +batch_remove_params: resend.Suppressions.Batch.RemoveParams = { + "ids": [entry["id"] for entry in batch_added["data"]], +} +batch_removed = resend.Suppressions.Batch.remove(batch_remove_params) +print(f"Batch removed {len(batch_removed['data'])} suppressions") + +# Remove: resume delivering to an address. +removed = resend.Suppressions.remove("blocked@example.com") +print(f"Removed suppression {removed['id']}: deleted={removed['deleted']}") diff --git a/resend/__init__.py b/resend/__init__.py index fe87b27..e1541a2 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -55,6 +55,12 @@ from .request import Request from .segments._segment import Segment from .segments._segments import Segments +from .suppressions._suppression import (Suppression, SuppressionListItem, + SuppressionOrigin) +from .suppressions._suppressions import Suppressions +from .suppressions.batch._suppressions_batch import (BatchRemovedSuppression, + BatchSuppression, + SuppressionsBatch) from .templates._template import Template, TemplateListItem, Variable from .templates._templates import Templates from .topics._topic import Topic @@ -114,6 +120,8 @@ "Topics", "Logs", "OAuthGrants", + "Suppressions", + "SuppressionsBatch", # Types "Audience", "Automation", @@ -152,6 +160,11 @@ "Tag", "Broadcast", "Segment", + "Suppression", + "SuppressionListItem", + "SuppressionOrigin", + "BatchSuppression", + "BatchRemovedSuppression", "Template", "TemplateListItem", "Variable", diff --git a/resend/suppressions/__init__.py b/resend/suppressions/__init__.py new file mode 100644 index 0000000..cbfe667 --- /dev/null +++ b/resend/suppressions/__init__.py @@ -0,0 +1,14 @@ +from ._suppression import Suppression, SuppressionListItem, SuppressionOrigin +from ._suppressions import Suppressions +from .batch._suppressions_batch import (BatchRemovedSuppression, + BatchSuppression, SuppressionsBatch) + +__all__ = [ + "Suppressions", + "Suppression", + "SuppressionListItem", + "SuppressionOrigin", + "SuppressionsBatch", + "BatchSuppression", + "BatchRemovedSuppression", +] diff --git a/resend/suppressions/_suppression.py b/resend/suppressions/_suppression.py new file mode 100644 index 0000000..2d27e45 --- /dev/null +++ b/resend/suppressions/_suppression.py @@ -0,0 +1,55 @@ +from typing import Optional + +from typing_extensions import Literal, TypedDict + +from resend._base_response import BaseResponse + +SuppressionOrigin = Literal[ + "bounce", + "complaint", + "manual", +] + + +class SuppressionListItem(TypedDict): + """ + SuppressionListItem represents a suppression in list responses. + + List entries do not carry the 'object' field that the get response includes. + + Attributes: + id (str): The ID of the suppression. + email (str): The suppressed email address. + origin (SuppressionOrigin): What caused the address to be suppressed. + source_id (Optional[str]): The ID of the event that caused the suppression, + such as the email that bounced or complained. None for manual suppressions. + created_at (str): The date and time the suppression was created. + """ + + id: str + email: str + origin: SuppressionOrigin + source_id: Optional[str] + created_at: str + + +class Suppression(BaseResponse): + """ + Suppression represents an email address that Resend will not deliver to. + + Attributes: + object (str): Always 'suppression'. + id (str): The ID of the suppression. + email (str): The suppressed email address. + origin (SuppressionOrigin): What caused the address to be suppressed. + source_id (Optional[str]): The ID of the event that caused the suppression, + such as the email that bounced or complained. None for manual suppressions. + created_at (str): The date and time the suppression was created. + """ + + object: str + id: str + email: str + origin: SuppressionOrigin + source_id: Optional[str] + created_at: str diff --git a/resend/suppressions/_suppressions.py b/resend/suppressions/_suppressions.py new file mode 100644 index 0000000..2b9185a --- /dev/null +++ b/resend/suppressions/_suppressions.py @@ -0,0 +1,259 @@ +from typing import Any, Dict, List, Optional, cast +from urllib.parse import quote + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.pagination_helper import PaginationHelper +from resend.suppressions.batch._suppressions_batch import SuppressionsBatch + +from ._suppression import Suppression, SuppressionListItem, SuppressionOrigin + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class Suppressions: + + class Batch(SuppressionsBatch): + pass + + class AddSuppressionResponse(BaseResponse): + """ + AddSuppressionResponse wraps the response of a created suppression. + + Attributes: + object (str): Always 'suppression'. + id (str): The ID of the created suppression. + """ + + object: str + id: str + + class RemoveSuppressionResponse(BaseResponse): + """ + RemoveSuppressionResponse wraps the response of a removed suppression. + + Attributes: + object (str): Always 'suppression'. + id (str): The ID of the removed suppression. + deleted (bool): Whether the suppression was deleted. + """ + + object: str + id: str + deleted: bool + + class ListResponse(BaseResponse): + """ + ListResponse wraps a paginated list of suppressions. + + Attributes: + object (str): Always 'list'. + data (List[SuppressionListItem]): The list of suppression entries. + has_more (bool): Whether additional pages of results exist. + """ + + object: str + data: List[SuppressionListItem] + has_more: bool + + class AddParams(TypedDict): + email: str + """ + The email address to suppress. + """ + + class ListParams(TypedDict): + origin: NotRequired[SuppressionOrigin] + """ + Filter suppressions by origin: 'bounce', 'complaint' or 'manual'. + """ + limit: NotRequired[int] + """ + Number of suppressions to retrieve. Maximum is 100, and minimum is 1. + """ + after: NotRequired[str] + """ + The ID after which we'll retrieve more suppressions (for pagination). + This ID will not be included in the returned list. + Cannot be used with the before parameter. + """ + before: NotRequired[str] + """ + The ID before which we'll retrieve more suppressions (for pagination). + This ID will not be included in the returned list. + Cannot be used with the after parameter. + """ + + @classmethod + def add(cls, params: AddParams) -> AddSuppressionResponse: + """ + Add an email address to the suppression list. The address is normalized + server-side, and adding an already-suppressed address returns the existing + suppression rather than an error. + see more: https://resend.com/docs/api-reference/suppressions/add-suppression + + Args: + params (AddParams): The suppression parameters + + Returns: + AddSuppressionResponse: The created suppression response + """ + path = "/suppressions" + resp = request.Request[Suppressions.AddSuppressionResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + def list(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of suppressions. + see more: https://resend.com/docs/api-reference/suppressions/list-suppressions + + Args: + params (Optional[ListParams]): Optional filtering and pagination parameters + + Returns: + ListResponse: A paginated list of suppression objects + """ + base_path = "/suppressions" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = request.Request[Suppressions.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def get(cls, id_or_email: str) -> Suppression: + """ + Retrieve a single suppression by ID or by email address. Returns a + not_found error if the address is not suppressed. + see more: https://resend.com/docs/api-reference/suppressions/get-suppression + + Args: + id_or_email (str): The suppression ID or the suppressed email address + + Returns: + Suppression: The suppression object + """ + if not id_or_email: + raise ValueError("id or email must be provided") + + path = f"/suppressions/{quote(id_or_email, safe='')}" + resp = request.Request[Suppression]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def remove(cls, id_or_email: str) -> RemoveSuppressionResponse: + """ + Remove a single suppression by ID or by email address. Returns a + not_found error if the address is not suppressed. + see more: https://resend.com/docs/api-reference/suppressions/remove-suppression + + Args: + id_or_email (str): The suppression ID or the suppressed email address + + Returns: + RemoveSuppressionResponse: The removed suppression response + """ + if not id_or_email: + raise ValueError("id or email must be provided") + + path = f"/suppressions/{quote(id_or_email, safe='')}" + resp = request.Request[Suppressions.RemoveSuppressionResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def add_async(cls, params: AddParams) -> AddSuppressionResponse: + """ + Add an email address to the suppression list (async). The address is + normalized server-side, and adding an already-suppressed address returns + the existing suppression rather than an error. + see more: https://resend.com/docs/api-reference/suppressions/add-suppression + + Args: + params (AddParams): The suppression parameters + + Returns: + AddSuppressionResponse: The created suppression response + """ + path = "/suppressions" + resp = await AsyncRequest[Suppressions.AddSuppressionResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of suppressions (async). + see more: https://resend.com/docs/api-reference/suppressions/list-suppressions + + Args: + params (Optional[ListParams]): Optional filtering and pagination parameters + + Returns: + ListResponse: A paginated list of suppression objects + """ + base_path = "/suppressions" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Suppressions.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, id_or_email: str) -> Suppression: + """ + Retrieve a single suppression by ID or by email address (async). Returns + a not_found error if the address is not suppressed. + see more: https://resend.com/docs/api-reference/suppressions/get-suppression + + Args: + id_or_email (str): The suppression ID or the suppressed email address + + Returns: + Suppression: The suppression object + """ + if not id_or_email: + raise ValueError("id or email must be provided") + + path = f"/suppressions/{quote(id_or_email, safe='')}" + resp = await AsyncRequest[Suppression]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, id_or_email: str) -> RemoveSuppressionResponse: + """ + Remove a single suppression by ID or by email address (async). Returns a + not_found error if the address is not suppressed. + see more: https://resend.com/docs/api-reference/suppressions/remove-suppression + + Args: + id_or_email (str): The suppression ID or the suppressed email address + + Returns: + RemoveSuppressionResponse: The removed suppression response + """ + if not id_or_email: + raise ValueError("id or email must be provided") + + path = f"/suppressions/{quote(id_or_email, safe='')}" + resp = await AsyncRequest[Suppressions.RemoveSuppressionResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/suppressions/batch/__init__.py b/resend/suppressions/batch/__init__.py new file mode 100644 index 0000000..0639f5f --- /dev/null +++ b/resend/suppressions/batch/__init__.py @@ -0,0 +1,4 @@ +from ._suppressions_batch import (BatchRemovedSuppression, BatchSuppression, + SuppressionsBatch) + +__all__ = ["SuppressionsBatch", "BatchSuppression", "BatchRemovedSuppression"] diff --git a/resend/suppressions/batch/_suppressions_batch.py b/resend/suppressions/batch/_suppressions_batch.py new file mode 100644 index 0000000..5821645 --- /dev/null +++ b/resend/suppressions/batch/_suppressions_batch.py @@ -0,0 +1,187 @@ +from typing import Any, Dict, List, cast + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class BatchSuppression(TypedDict): + """ + BatchSuppression is a single entry of a batch add response. + + Attributes: + object (str): Always 'suppression'. + id (str): The ID of the suppression. + """ + + object: str + id: str + + +class BatchRemovedSuppression(TypedDict): + """ + BatchRemovedSuppression is a single entry of a batch remove response. + + Attributes: + object (str): Always 'suppression'. + id (str): The ID of the removed suppression. + deleted (bool): Whether the suppression was deleted. + """ + + object: str + id: str + deleted: bool + + +class SuppressionsBatch: + class AddParams(TypedDict): + emails: List[str] + """ + The email addresses to suppress. Between 1 and 100 addresses. + """ + + class RemoveParams(TypedDict): + emails: NotRequired[List[str]] + """ + The email addresses to remove from the suppression list. + Between 1 and 100 addresses. Cannot be used with the ids parameter. + """ + ids: NotRequired[List[str]] + """ + The suppression IDs to remove from the suppression list. Must be UUIDs. + Between 1 and 100 IDs. Cannot be used with the emails parameter. + """ + + class AddResponse(BaseResponse): + """ + AddResponse wraps the suppressions created by a batch add. + + Attributes: + data (List[BatchSuppression]): The created suppressions. + """ + + data: List[BatchSuppression] + + class RemoveResponse(BaseResponse): + """ + RemoveResponse wraps the suppressions removed by a batch remove. + + Attributes: + data (List[BatchRemovedSuppression]): The removed suppressions. + """ + + data: List[BatchRemovedSuppression] + + # The API rejects `null` for the unset key, so RemoveParams uses NotRequired + # (absent from the JSON body) rather than Optional, and the exclusivity the + # server enforces is checked here since a TypedDict cannot express it. The + # check and the body are built together so "None means absent" cannot drift + # apart from what gets serialized. + @staticmethod + def _build_remove_body( + params: "SuppressionsBatch.RemoveParams", + ) -> Dict[str, Any]: + has_emails = params.get("emails") is not None + has_ids = params.get("ids") is not None + if has_emails and has_ids: + raise ValueError("emails and ids cannot be used together") + if not has_emails and not has_ids: + raise ValueError("emails or ids must be provided") + return {key: value for key, value in params.items() if value is not None} + + @classmethod + def add(cls, params: AddParams) -> AddResponse: + """ + Add up to 100 email addresses to the suppression list at once. Addresses are + normalized and deduplicated server-side, and already-suppressed addresses + return their existing suppression rather than an error. + see more: https://resend.com/docs/api-reference/suppressions/add-suppressions + + Args: + params (AddParams): The batch add parameters + + Returns: + AddResponse: The created suppressions. May contain fewer entries than + emails sent, since duplicates collapse into one. Do not match + entries to the emails you sent by index. + """ + path = "/suppressions/batch/add" + resp = request.Request[SuppressionsBatch.AddResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + def remove(cls, params: RemoveParams) -> RemoveResponse: + """ + Remove up to 100 suppressions at once, by email address or by ID. + see more: https://resend.com/docs/api-reference/suppressions/remove-suppressions + + Args: + params (RemoveParams): The batch remove parameters. Provide either + emails or ids, but not both. + + Returns: + RemoveResponse: The removed suppressions. Only identifiers that were + actually suppressed are returned, so this may contain fewer entries + than you sent - misses are omitted rather than reported as errors. + Do not match entries to the identifiers you sent by index. + """ + body = cls._build_remove_body(params) + path = "/suppressions/batch/remove" + resp = request.Request[SuppressionsBatch.RemoveResponse]( + path=path, params=body, verb="post" + ).perform_with_content() + return resp + + @classmethod + async def add_async(cls, params: AddParams) -> AddResponse: + """ + Add up to 100 email addresses to the suppression list at once (async). + Addresses are normalized and deduplicated server-side, and already-suppressed + addresses return their existing suppression rather than an error. + see more: https://resend.com/docs/api-reference/suppressions/add-suppressions + + Args: + params (AddParams): The batch add parameters + + Returns: + AddResponse: The created suppressions. May contain fewer entries than + emails sent, since duplicates collapse into one. Do not match + entries to the emails you sent by index. + """ + path = "/suppressions/batch/add" + resp = await AsyncRequest[SuppressionsBatch.AddResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, params: RemoveParams) -> RemoveResponse: + """ + Remove up to 100 suppressions at once, by email address or by ID (async). + see more: https://resend.com/docs/api-reference/suppressions/remove-suppressions + + Args: + params (RemoveParams): The batch remove parameters. Provide either + emails or ids, but not both. + + Returns: + RemoveResponse: The removed suppressions. Only identifiers that were + actually suppressed are returned, so this may contain fewer entries + than you sent - misses are omitted rather than reported as errors. + Do not match entries to the identifiers you sent by index. + """ + body = cls._build_remove_body(params) + path = "/suppressions/batch/remove" + resp = await AsyncRequest[SuppressionsBatch.RemoveResponse]( + path=path, params=body, verb="post" + ).perform_with_content() + return resp diff --git a/resend/suppressions/py.typed b/resend/suppressions/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/suppressions_async_test.py b/tests/suppressions_async_test.py new file mode 100644 index 0000000..ba28c69 --- /dev/null +++ b/tests/suppressions_async_test.py @@ -0,0 +1,412 @@ +from typing import Any, Dict, cast +from unittest.mock import AsyncMock + +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestSuppressionsAsync(AsyncResendBaseTest): + async def test_suppressions_add_async(self) -> None: + self.set_mock_json( + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + } + ) + + params: resend.Suppressions.AddParams = {"email": "blocked@example.com"} + added = await resend.Suppressions.add_async(params) + assert added["object"] == "suppression" + assert added["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + + async def test_should_add_suppression_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Suppressions.AddParams = {"email": "blocked@example.com"} + with pytest.raises(NoContentError): + _ = await resend.Suppressions.add_async(params) + + async def test_suppressions_list_async(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email": "bounced@example.com", + "origin": "bounce", + "source_id": "479e3145-dd38-476b-932c-529ceb705947", + "created_at": "2023-10-06T23:47:56.678Z", + }, + { + "id": "fd61172c-cafc-40f5-b049-b45947779a29", + "email": "manual@example.com", + "origin": "manual", + "source_id": None, + "created_at": "2023-10-07T23:47:56.678Z", + }, + ], + } + ) + + suppressions = await resend.Suppressions.list_async() + assert suppressions["object"] == "list" + assert suppressions["has_more"] is False + assert suppressions["data"][0]["origin"] == "bounce" + assert ( + suppressions["data"][0]["source_id"] + == "479e3145-dd38-476b-932c-529ceb705947" + ) + assert suppressions["data"][1]["source_id"] is None + assert "object" not in suppressions["data"][0] + + async def test_suppressions_list_async_with_params(self) -> None: + self.set_mock_json({"object": "list", "has_more": False, "data": []}) + + params: resend.Suppressions.ListParams = { + "origin": "complaint", + "limit": 25, + } + suppressions = await resend.Suppressions.list_async(params) + assert suppressions["has_more"] is False + assert ( + self.mock.call_args.kwargs["url"] + == "https://api.resend.com/suppressions?origin=complaint&limit=25" + ) + + async def test_should_list_suppressions_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Suppressions.list_async() + + async def test_suppressions_get_async(self) -> None: + self.set_mock_json( + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email": "bounced@example.com", + "origin": "bounce", + "source_id": "479e3145-dd38-476b-932c-529ceb705947", + "created_at": "2023-10-06T23:47:56.678Z", + } + ) + + suppression = await resend.Suppressions.get_async( + "e169aa45-1ecf-4183-9955-b1499d5701d3" + ) + assert suppression["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + assert suppression["email"] == "bounced@example.com" + assert suppression["source_id"] == "479e3145-dd38-476b-932c-529ceb705947" + + async def test_suppressions_get_async_with_null_source_id(self) -> None: + self.set_mock_json( + { + "object": "suppression", + "id": "fd61172c-cafc-40f5-b049-b45947779a29", + "email": "manual@example.com", + "origin": "manual", + "source_id": None, + "created_at": "2023-10-06T23:47:56.678Z", + } + ) + + suppression = await resend.Suppressions.get_async("manual@example.com") + assert suppression["origin"] == "manual" + assert suppression["source_id"] is None + + async def test_suppressions_get_async_encodes_email_identifier(self) -> None: + self.set_mock_json( + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email": "user+tag@example.com", + "origin": "manual", + "source_id": None, + "created_at": "2023-10-06T23:47:56.678Z", + } + ) + + suppression = await resend.Suppressions.get_async("user+tag@example.com") + assert suppression["email"] == "user+tag@example.com" + assert ( + self.mock.call_args.kwargs["url"] + == "https://api.resend.com/suppressions/user%2Btag%40example.com" + ) + + async def test_suppressions_get_async_raises_without_identifier(self) -> None: + with pytest.raises(ValueError): + _ = await resend.Suppressions.get_async("") + + async def test_should_get_suppression_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Suppressions.get_async( + "e169aa45-1ecf-4183-9955-b1499d5701d3" + ) + + async def test_suppressions_remove_async(self) -> None: + self.set_mock_json( + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "deleted": True, + } + ) + + removed = await resend.Suppressions.remove_async("blocked@example.com") + assert removed["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + assert removed["deleted"] is True + assert ( + self.mock.call_args.kwargs["url"] + == "https://api.resend.com/suppressions/blocked%40example.com" + ) + + async def test_suppressions_remove_async_raises_without_identifier(self) -> None: + with pytest.raises(ValueError): + _ = await resend.Suppressions.remove_async("") + + async def test_should_remove_suppression_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Suppressions.remove_async("blocked@example.com") + + async def test_suppressions_batch_add_async(self) -> None: + self.set_mock_json( + { + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + }, + { + "object": "suppression", + "id": "fd61172c-cafc-40f5-b049-b45947779a29", + }, + ] + } + ) + + params: resend.Suppressions.Batch.AddParams = { + "emails": ["one@example.com", "two@example.com"], + } + added = await resend.Suppressions.Batch.add_async(params) + assert len(added["data"]) == 2 + assert added["data"][0]["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + + async def test_suppressions_batch_add_async_dedupes_server_side(self) -> None: + self.set_mock_json( + { + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + } + ] + } + ) + + params: resend.Suppressions.Batch.AddParams = { + "emails": ["ONE@example.com", "one@example.com", " one@example.com "], + } + added = await resend.Suppressions.Batch.add_async(params) + assert len(added["data"]) == 1 + + async def test_should_batch_add_suppressions_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Suppressions.Batch.AddParams = {"emails": ["one@example.com"]} + with pytest.raises(NoContentError): + _ = await resend.Suppressions.Batch.add_async(params) + + async def test_suppressions_batch_remove_async_with_emails(self) -> None: + self.set_mock_json( + { + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "deleted": True, + } + ] + } + ) + + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["one@example.com"], + } + removed = await resend.Suppressions.Batch.remove_async(params) + assert removed["data"][0]["deleted"] is True + + async def test_suppressions_batch_remove_async_with_ids(self) -> None: + self.set_mock_json( + { + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "deleted": True, + }, + { + "object": "suppression", + "id": "fd61172c-cafc-40f5-b049-b45947779a29", + "deleted": True, + }, + ] + } + ) + + params: resend.Suppressions.Batch.RemoveParams = { + "ids": [ + "e169aa45-1ecf-4183-9955-b1499d5701d3", + "fd61172c-cafc-40f5-b049-b45947779a29", + ], + } + removed = await resend.Suppressions.Batch.remove_async(params) + assert len(removed["data"]) == 2 + assert removed["data"][1]["deleted"] is True + + async def test_suppressions_batch_remove_async_omits_identifiers_not_suppressed( + self, + ) -> None: + self.set_mock_json( + { + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "deleted": True, + } + ] + } + ) + + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["suppressed@example.com", "never-suppressed@example.com"], + } + removed = await resend.Suppressions.Batch.remove_async(params) + assert len(removed["data"]) == 1 + assert removed["data"][0]["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + + async def test_suppressions_batch_remove_async_with_no_matches(self) -> None: + self.set_mock_json({"data": []}) + + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["never-suppressed@example.com"], + } + removed = await resend.Suppressions.Batch.remove_async(params) + assert removed["data"] == [] + + async def test_suppressions_batch_remove_async_raises_when_both_provided( + self, + ) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["one@example.com"], + "ids": ["e169aa45-1ecf-4183-9955-b1499d5701d3"], + } + with pytest.raises(ValueError): + _ = await resend.Suppressions.Batch.remove_async(params) + + async def test_suppressions_batch_remove_async_raises_when_neither_provided( + self, + ) -> None: + params: resend.Suppressions.Batch.RemoveParams = {} + with pytest.raises(ValueError): + _ = await resend.Suppressions.Batch.remove_async(params) + + async def test_should_batch_remove_suppressions_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["one@example.com"], + } + with pytest.raises(NoContentError): + _ = await resend.Suppressions.Batch.remove_async(params) + + +class TestSuppressionsRequestBodyAsync: + def setup_method(self) -> None: + resend.api_key = "re_123" + self.mock_client = AsyncMock() + self.mock_client.request.return_value = ( + b'{"data": []}', + 200, + {"content-type": "application/json"}, + ) + self.previous_async_http_client = resend.default_async_http_client + resend.default_async_http_client = self.mock_client + + def teardown_method(self) -> None: + resend.default_async_http_client = self.previous_async_http_client + + async def test_batch_remove_async_omits_the_unset_key(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["one@example.com"], + } + await resend.Suppressions.Batch.remove_async(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["url"] == "https://api.resend.com/suppressions/batch/remove" + assert kwargs["json"] == {"emails": ["one@example.com"]} + assert "ids" not in kwargs["json"] + + async def test_batch_remove_async_omits_explicit_none_ids(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["one@example.com"], + "ids": None, # type: ignore[typeddict-item] + } + await resend.Suppressions.Batch.remove_async(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["json"] == {"emails": ["one@example.com"]} + assert "ids" not in kwargs["json"] + + async def test_batch_remove_async_omits_explicit_none_emails(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "ids": ["e169aa45-1ecf-4183-9955-b1499d5701d3"], + "emails": None, # type: ignore[typeddict-item] + } + await resend.Suppressions.Batch.remove_async(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["json"] == {"ids": ["e169aa45-1ecf-4183-9955-b1499d5701d3"]} + assert "emails" not in kwargs["json"] + + async def test_batch_remove_async_omits_none_from_dynamically_built_params( + self, + ) -> None: + params: Dict[str, Any] = {} + params["ids"] = None + params["emails"] = ["one@example.com"] + await resend.Suppressions.Batch.remove_async( + cast("resend.Suppressions.Batch.RemoveParams", params) + ) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["json"] == {"emails": ["one@example.com"]} + assert "ids" not in kwargs["json"] + + async def test_batch_remove_async_raises_when_both_keys_are_none(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "emails": None, # type: ignore[typeddict-item] + "ids": None, # type: ignore[typeddict-item] + } + with pytest.raises(ValueError): + await resend.Suppressions.Batch.remove_async(params) + self.mock_client.request.assert_not_called() diff --git a/tests/suppressions_test.py b/tests/suppressions_test.py new file mode 100644 index 0000000..0b37133 --- /dev/null +++ b/tests/suppressions_test.py @@ -0,0 +1,457 @@ +from typing import Any, Dict, cast +from unittest import TestCase +from unittest.mock import create_autospec + +import resend +from resend.exceptions import NoContentError +from resend.http_client import HTTPClient +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +class TestSuppressions(ResendBaseTest): + def test_suppressions_add(self) -> None: + self.set_mock_json( + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + } + ) + + params: resend.Suppressions.AddParams = {"email": "blocked@example.com"} + added: resend.Suppressions.AddSuppressionResponse = resend.Suppressions.add( + params + ) + assert added["object"] == "suppression" + assert added["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + + def test_should_add_suppression_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + params: resend.Suppressions.AddParams = {"email": "blocked@example.com"} + with self.assertRaises(NoContentError): + _ = resend.Suppressions.add(params) + + def test_suppressions_list(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": True, + "data": [ + { + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email": "bounced@example.com", + "origin": "bounce", + "source_id": "479e3145-dd38-476b-932c-529ceb705947", + "created_at": "2023-10-06T23:47:56.678Z", + }, + { + "id": "fd61172c-cafc-40f5-b049-b45947779a29", + "email": "manual@example.com", + "origin": "manual", + "source_id": None, + "created_at": "2023-10-07T23:47:56.678Z", + }, + ], + } + ) + + suppressions: resend.Suppressions.ListResponse = resend.Suppressions.list() + assert suppressions["object"] == "list" + assert suppressions["has_more"] is True + + bounced = suppressions["data"][0] + assert bounced["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + assert bounced["email"] == "bounced@example.com" + assert bounced["origin"] == "bounce" + assert bounced["source_id"] == "479e3145-dd38-476b-932c-529ceb705947" + assert bounced["created_at"] == "2023-10-06T23:47:56.678Z" + + manual = suppressions["data"][1] + assert manual["origin"] == "manual" + assert manual["source_id"] is None + + def test_suppressions_list_entries_have_no_object_field(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email": "bounced@example.com", + "origin": "bounce", + "source_id": "479e3145-dd38-476b-932c-529ceb705947", + "created_at": "2023-10-06T23:47:56.678Z", + } + ], + } + ) + + suppressions = resend.Suppressions.list() + assert "object" not in suppressions["data"][0] + assert sorted(suppressions["data"][0].keys()) == [ + "created_at", + "email", + "id", + "origin", + "source_id", + ] + + def test_suppressions_list_with_params(self) -> None: + self.set_mock_json({"object": "list", "has_more": False, "data": []}) + + params: resend.Suppressions.ListParams = { + "origin": "complaint", + "limit": 25, + "after": "e169aa45-1ecf-4183-9955-b1499d5701d3", + } + suppressions = resend.Suppressions.list(params) + assert suppressions["has_more"] is False + assert ( + self.mock.call_args.kwargs["url"] + == "https://api.resend.com/suppressions?origin=complaint&limit=25&after=e169aa45-1ecf-4183-9955-b1499d5701d3" + ) + + def test_should_list_suppressions_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Suppressions.list() + + def test_suppressions_get(self) -> None: + self.set_mock_json( + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email": "bounced@example.com", + "origin": "bounce", + "source_id": "479e3145-dd38-476b-932c-529ceb705947", + "created_at": "2023-10-06T23:47:56.678Z", + } + ) + + suppression: resend.Suppression = resend.Suppressions.get( + "e169aa45-1ecf-4183-9955-b1499d5701d3" + ) + assert suppression["object"] == "suppression" + assert suppression["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + assert suppression["email"] == "bounced@example.com" + assert suppression["origin"] == "bounce" + assert suppression["source_id"] == "479e3145-dd38-476b-932c-529ceb705947" + + def test_suppressions_get_with_null_source_id(self) -> None: + self.set_mock_json( + { + "object": "suppression", + "id": "fd61172c-cafc-40f5-b049-b45947779a29", + "email": "manual@example.com", + "origin": "manual", + "source_id": None, + "created_at": "2023-10-06T23:47:56.678Z", + } + ) + + suppression = resend.Suppressions.get("manual@example.com") + assert suppression["origin"] == "manual" + assert suppression["source_id"] is None + + def test_suppressions_get_encodes_email_identifier(self) -> None: + self.set_mock_json( + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email": "user+tag@example.com", + "origin": "manual", + "source_id": None, + "created_at": "2023-10-06T23:47:56.678Z", + } + ) + + suppression = resend.Suppressions.get("user+tag@example.com") + assert suppression["email"] == "user+tag@example.com" + assert ( + self.mock.call_args.kwargs["url"] + == "https://api.resend.com/suppressions/user%2Btag%40example.com" + ) + + def test_suppressions_get_raises_without_identifier(self) -> None: + with self.assertRaises(ValueError): + _ = resend.Suppressions.get("") + + def test_should_get_suppression_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Suppressions.get("e169aa45-1ecf-4183-9955-b1499d5701d3") + + def test_suppressions_remove(self) -> None: + self.set_mock_json( + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "deleted": True, + } + ) + + removed: resend.Suppressions.RemoveSuppressionResponse = ( + resend.Suppressions.remove("blocked@example.com") + ) + assert removed["object"] == "suppression" + assert removed["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + assert removed["deleted"] is True + assert ( + self.mock.call_args.kwargs["url"] + == "https://api.resend.com/suppressions/blocked%40example.com" + ) + + def test_suppressions_remove_raises_without_identifier(self) -> None: + with self.assertRaises(ValueError): + _ = resend.Suppressions.remove("") + + def test_should_remove_suppression_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Suppressions.remove("blocked@example.com") + + def test_suppressions_batch_add(self) -> None: + self.set_mock_json( + { + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + }, + { + "object": "suppression", + "id": "fd61172c-cafc-40f5-b049-b45947779a29", + }, + ] + } + ) + + params: resend.Suppressions.Batch.AddParams = { + "emails": ["one@example.com", "two@example.com"], + } + added: resend.Suppressions.Batch.AddResponse = resend.Suppressions.Batch.add( + params + ) + assert len(added["data"]) == 2 + assert added["data"][0]["object"] == "suppression" + assert added["data"][0]["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + assert added["data"][1]["id"] == "fd61172c-cafc-40f5-b049-b45947779a29" + + def test_suppressions_batch_add_dedupes_server_side(self) -> None: + self.set_mock_json( + { + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + } + ] + } + ) + + params: resend.Suppressions.Batch.AddParams = { + "emails": [ + "ONE@example.com", + "one@example.com", + " one@example.com ", + ], + } + added = resend.Suppressions.Batch.add(params) + assert len(added["data"]) == 1 + assert added["data"][0]["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + + def test_should_batch_add_suppressions_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Suppressions.Batch.AddParams = {"emails": ["one@example.com"]} + with self.assertRaises(NoContentError): + _ = resend.Suppressions.Batch.add(params) + + def test_suppressions_batch_remove_with_emails(self) -> None: + self.set_mock_json( + { + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "deleted": True, + } + ] + } + ) + + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["one@example.com"], + } + removed: resend.Suppressions.Batch.RemoveResponse = ( + resend.Suppressions.Batch.remove(params) + ) + assert removed["data"][0]["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + assert removed["data"][0]["deleted"] is True + + def test_suppressions_batch_remove_with_ids(self) -> None: + self.set_mock_json( + { + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "deleted": True, + }, + { + "object": "suppression", + "id": "fd61172c-cafc-40f5-b049-b45947779a29", + "deleted": True, + }, + ] + } + ) + + params: resend.Suppressions.Batch.RemoveParams = { + "ids": [ + "e169aa45-1ecf-4183-9955-b1499d5701d3", + "fd61172c-cafc-40f5-b049-b45947779a29", + ], + } + removed = resend.Suppressions.Batch.remove(params) + assert len(removed["data"]) == 2 + assert removed["data"][0]["deleted"] is True + assert removed["data"][1]["deleted"] is True + + def test_suppressions_batch_remove_omits_identifiers_that_were_not_suppressed( + self, + ) -> None: + self.set_mock_json( + { + "data": [ + { + "object": "suppression", + "id": "e169aa45-1ecf-4183-9955-b1499d5701d3", + "deleted": True, + } + ] + } + ) + + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["suppressed@example.com", "never-suppressed@example.com"], + } + removed = resend.Suppressions.Batch.remove(params) + assert len(removed["data"]) == 1 + assert removed["data"][0]["id"] == "e169aa45-1ecf-4183-9955-b1499d5701d3" + + def test_suppressions_batch_remove_with_no_matches(self) -> None: + self.set_mock_json({"data": []}) + + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["never-suppressed@example.com"], + } + removed = resend.Suppressions.Batch.remove(params) + assert removed["data"] == [] + + def test_suppressions_batch_remove_raises_when_both_provided(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["one@example.com"], + "ids": ["e169aa45-1ecf-4183-9955-b1499d5701d3"], + } + with self.assertRaises(ValueError): + _ = resend.Suppressions.Batch.remove(params) + + def test_suppressions_batch_remove_raises_when_neither_provided(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = {} + with self.assertRaises(ValueError): + _ = resend.Suppressions.Batch.remove(params) + + def test_should_batch_remove_suppressions_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["one@example.com"], + } + with self.assertRaises(NoContentError): + _ = resend.Suppressions.Batch.remove(params) + + +class TestSuppressionsRequestBody(TestCase): + def setUp(self) -> None: + resend.api_key = "re_123" + self.mock_client = create_autospec(HTTPClient, instance=True) + self.mock_client.name = "mock" + self.mock_client.request.return_value = ( + b'{"data": []}', + 200, + {"Content-Type": "application/json"}, + ) + self.previous_http_client = resend.default_http_client + resend.default_http_client = self.mock_client + + def tearDown(self) -> None: + resend.default_http_client = self.previous_http_client + + def test_batch_remove_omits_the_unset_key(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["one@example.com"], + } + resend.Suppressions.Batch.remove(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["url"] == "https://api.resend.com/suppressions/batch/remove" + assert kwargs["json"] == {"emails": ["one@example.com"]} + assert "ids" not in kwargs["json"] + + def test_batch_remove_with_ids_omits_emails(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "ids": ["e169aa45-1ecf-4183-9955-b1499d5701d3"], + } + resend.Suppressions.Batch.remove(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["json"] == {"ids": ["e169aa45-1ecf-4183-9955-b1499d5701d3"]} + assert "emails" not in kwargs["json"] + + def test_batch_remove_omits_explicit_none_ids(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "emails": ["one@example.com"], + "ids": None, # type: ignore[typeddict-item] + } + resend.Suppressions.Batch.remove(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["json"] == {"emails": ["one@example.com"]} + assert "ids" not in kwargs["json"] + + def test_batch_remove_omits_explicit_none_emails(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "ids": ["e169aa45-1ecf-4183-9955-b1499d5701d3"], + "emails": None, # type: ignore[typeddict-item] + } + resend.Suppressions.Batch.remove(params) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["json"] == {"ids": ["e169aa45-1ecf-4183-9955-b1499d5701d3"]} + assert "emails" not in kwargs["json"] + + def test_batch_remove_omits_none_from_dynamically_built_params(self) -> None: + params: Dict[str, Any] = {} + params["ids"] = None + params["emails"] = ["one@example.com"] + resend.Suppressions.Batch.remove( + cast("resend.Suppressions.Batch.RemoveParams", params) + ) + + _, kwargs = self.mock_client.request.call_args + assert kwargs["json"] == {"emails": ["one@example.com"]} + assert "ids" not in kwargs["json"] + + def test_batch_remove_raises_when_both_keys_are_none(self) -> None: + params: resend.Suppressions.Batch.RemoveParams = { + "emails": None, # type: ignore[typeddict-item] + "ids": None, # type: ignore[typeddict-item] + } + with self.assertRaises(ValueError): + resend.Suppressions.Batch.remove(params) + self.mock_client.request.assert_not_called()