diff --git a/README.md b/README.md index 3596abf..7d2514b 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,44 @@ params: resend.Emails.SendParams = { email: resend.Emails.SendResponse = resend.Emails.send(params) print(email) ``` + +## Async Support + +The SDK supports async operations for improved performance in async applications. To use async features, you need to install the async dependencies: + +```bash +pip install resend[async] +``` + +### Async Example + +```py +import asyncio +import os +import resend + +resend.api_key = "re_yourkey" +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + +async def main(): + params: resend.Emails.SendParams = { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "hi", + "html": "hello, world!", + "reply_to": "to@gmail.com", + "bcc": "bcc@resend.dev", + "cc": ["cc@resend.dev"], + "tags": [ + {"name": "tag1", "value": "tagvalue1"}, + {"name": "tag2", "value": "tagvalue2"}, + ], + } + + email: resend.Emails.SendResponse = await resend.Emails.send_async(params) + print(email) + +if __name__ == "__main__": + asyncio.run(main()) +``` diff --git a/examples/api_keys_async.py b/examples/api_keys_async.py new file mode 100644 index 0000000..b34661f --- /dev/null +++ b/examples/api_keys_async.py @@ -0,0 +1,35 @@ +import asyncio +import os +from typing import List + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + + +async def main() -> None: + create_params: resend.ApiKeys.CreateParams = { + "name": "example.com", + } + + key = await resend.ApiKeys.create_async(params=create_params) + print("Created new api key") + print(f"Key id: {key['id']} and token: {key['token']}") + + keys: resend.ApiKeys.ListResponse = await resend.ApiKeys.list_async() + for k in keys["data"]: + print(k["id"]) + print(k["name"]) + print(k["created_at"]) + + if len(keys["data"]) > 0: + await resend.ApiKeys.remove_async(api_key_id=keys["data"][0]["id"]) + print(f"Removed api key: {keys['data'][0]['id']}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/audiences_async.py b/examples/audiences_async.py new file mode 100644 index 0000000..1b36b0b --- /dev/null +++ b/examples/audiences_async.py @@ -0,0 +1,37 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + + +async def main() -> None: + create_params: resend.Segments.CreateParams = { + "name": "New Segment from Python SDK (Async)", + } + segment: resend.Segments.CreateSegmentResponse = await resend.Segments.create_async( + create_params + ) + print(f"Created segment: {segment['id']}") + print(segment) + + seg: resend.Segment = await resend.Segments.get_async(segment["id"]) + print("Retrieved segment: ", seg) + + segments: resend.Segments.ListResponse = await resend.Segments.list_async() + print("List of segments:", [s["id"] for s in segments["data"]]) + + rmed: resend.Segments.RemoveSegmentResponse = await resend.Segments.remove_async( + id=segment["id"] + ) + print("Deleted segment") + print(rmed) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/batch_email_send_async.py b/examples/batch_email_send_async.py new file mode 100644 index 0000000..1336791 --- /dev/null +++ b/examples/batch_email_send_async.py @@ -0,0 +1,62 @@ +import asyncio +import os +from typing import List + +import resend +import resend.exceptions + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + + +async def main() -> None: + params: List[resend.Emails.SendParams] = [ + { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "hey", + "html": "hello, world!", + }, + { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "hello", + "html": "hello, world!", + }, + ] + + try: + # Send batch emails + print("sending without idempotency_key") + emails: resend.Batch.SendResponse = await resend.Batch.send_async(params) + for email in emails["data"]: + print(f"Email id: {email['id']}") + except resend.exceptions.ResendError as err: + print("Failed to send batch emails") + print(f"Error: {err}") + exit(1) + + try: + # Send batch emails with idempotency_key + print("sending with idempotency_key") + + options: resend.Batch.SendOptions = { + "idempotency_key": "af477dc78aa9fa91fff3b8c0d4a2e1a5", + } + + e: resend.Batch.SendResponse = await resend.Batch.send_async( + params, options=options + ) + for email in e["data"]: + print(f"Email id: {email['id']}") + except resend.exceptions.ResendError as err: + print("Failed to send batch emails") + print(f"Error: {err}") + exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/broadcasts_async.py b/examples/broadcasts_async.py new file mode 100644 index 0000000..368e415 --- /dev/null +++ b/examples/broadcasts_async.py @@ -0,0 +1,77 @@ +import asyncio +import os +from typing import List + +import resend +import resend.broadcasts + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + +# replace with some existing audience id +audience_id: str = "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e" + + +async def main() -> None: + create_params: resend.Broadcasts.CreateParams = { + "audience_id": audience_id, + "from": "onboarding@resend.dev", + "subject": "Hello, world! (Async)", + "html": "
Hello, world!
", + "text": "Hello, world!", + "reply_to": ["foo@resend.dev", "bar@resend.dev"], + "name": "Hello, world! (Async)", + } + + broadcast: resend.Broadcasts.CreateResponse = await resend.Broadcasts.create_async( + create_params + ) + print("Created broadcast !") + print(broadcast) + + update_params: resend.Broadcasts.UpdateParams = { + "broadcast_id": broadcast["id"], + "html": "Hello, world! Updated (Async)
", + "text": "Hello, world! Updated (Async)", + "name": "Hello, world! Updated (Async)", + } + + updated_broadcast: resend.Broadcasts.UpdateResponse = ( + await resend.Broadcasts.update_async(update_params) + ) + print("Updated broadcast!") + print(updated_broadcast) + + send_params: resend.Broadcasts.SendParams = { + "broadcast_id": broadcast["id"], + } + sent: resend.Broadcasts.SendResponse = await resend.Broadcasts.send_async( + send_params + ) + print("Sent broadcast !\n") + print(sent) + + retrieved: resend.Broadcast = await resend.Broadcasts.get_async(id=broadcast["id"]) + print("retrieved broadcast !\n") + print(retrieved) + + if retrieved["status"] == "draft": + removed: resend.Broadcasts.RemoveResponse = ( + await resend.Broadcasts.remove_async(id=broadcast["id"]) + ) + print("Removed broadcast !\n") + print(removed) + print("\n") + else: + print("Broadcast is not in draft status, cannot remove it.\n") + + list_response: resend.Broadcasts.ListResponse = await resend.Broadcasts.list_async() + print("List of broadcasts !\n") + print(list_response) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/contacts_async.py b/examples/contacts_async.py new file mode 100644 index 0000000..22a96a7 --- /dev/null +++ b/examples/contacts_async.py @@ -0,0 +1,76 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + +# replace with some audience id +audience_id: str = "ca4e37c5-a82a-4199-a3b8-bf912a6472aa" + + +async def main() -> None: + create_params: resend.Contacts.CreateParams = { + "audience_id": audience_id, + "email": "sw@exmple.com", + "first_name": "Steve", + "last_name": "Wozniak", + "unsubscribed": False, + } + + contact: resend.Contacts.CreateContactResponse = await resend.Contacts.create_async( + create_params + ) + print("Created contact !") + print(contact) + + update_params: resend.Contacts.UpdateParams = { + "audience_id": audience_id, + "id": contact["id"], + "unsubscribed": False, + "first_name": "Steve (Async)", + } + + updated: resend.Contacts.UpdateContactResponse = await resend.Contacts.update_async( + update_params + ) + print("updated contact !") + print(updated) + + cont_by_id: resend.Contact = await resend.Contacts.get_async( + id=contact["id"], audience_id=audience_id + ) + print("Retrieved contact by ID") + print(cont_by_id) + + cont_by_email: resend.Contact = await resend.Contacts.get_async( + email="sw@exmple.com", audience_id=audience_id + ) + print("Retrieved contact by Email") + print(cont_by_email) + + contacts: resend.Contacts.ListResponse = await resend.Contacts.list_async( + audience_id=audience_id + ) + print("List of contacts") + for c in contacts["data"]: + print(c) + + # remove by email + rmed = await resend.Contacts.remove_async( + audience_id=audience_id, email=cont_by_email["email"] + ) + + # remove by id + # rmed: resend.Contact = await resend.Contacts.remove_async(audience_id=audience_id, id=cont["id"]) + + print(f"Removed contact") + print(rmed) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/domains_async.py b/examples/domains_async.py new file mode 100644 index 0000000..4a6c0d8 --- /dev/null +++ b/examples/domains_async.py @@ -0,0 +1,56 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + + +async def main() -> None: + create_params: resend.Domains.CreateParams = { + "name": "example.com", + "region": "us-east-1", + "custom_return_path": "outbound", + } + domain: resend.Domains.CreateDomainResponse = await resend.Domains.create_async( + params=create_params + ) + print(domain) + + retrieved: resend.Domain = await resend.Domains.get_async(domain_id=domain["id"]) + if retrieved["records"] is not None: + for record in retrieved["records"]: + print(record) + + update_params: resend.Domains.UpdateParams = { + "id": domain["id"], + "open_tracking": True, + "click_tracking": True, + "tls": "enforced", + } + + updated_domain: resend.Domain = await resend.Domains.update_async(update_params) + print(f"Updated domain: {updated_domain['id']}") + + domains: resend.Domains.ListResponse = await resend.Domains.list_async() + if not domains: + print("No domains found") + for d in domains["data"]: + print(d) + + verified_domain: resend.Domain = await resend.Domains.verify_async( + domain_id=domain["id"] + ) + print("Verified") + print(verified_domain) + + rm_domain: resend.Domain = await resend.Domains.remove_async(domain_id=domain["id"]) + print(rm_domain) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/simple_email_async.py b/examples/simple_email_async.py new file mode 100644 index 0000000..f52f350 --- /dev/null +++ b/examples/simple_email_async.py @@ -0,0 +1,59 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +# Set up async HTTP client +resend.default_http_client = resend.HTTPXClient() + +params: resend.Emails.SendParams = { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "hi", + "html": "hello, world!", + "reply_to": "to@gmail.com", + "bcc": "delivered@resend.dev", + "cc": ["delivered@resend.dev"], + "tags": [ + {"name": "tag1", "value": "tagvalue1"}, + {"name": "tag2", "value": "tagvalue2"}, + ], +} + + +async def main() -> None: + # Without Idempotency Key + email_non_idempotent: resend.Emails.SendResponse = await resend.Emails.send_async( + params + ) + print(f"Sent email without idempotency key: {email_non_idempotent['id']}") + + # With Idempotency Key + options: resend.Emails.SendOptions = { + "idempotency_key": "44", + } + email_idempotent: resend.Emails.SendResponse = await resend.Emails.send_async( + params, options + ) + print(f"Sent email with idempotency key: {email_idempotent['id']}") + + email_resp: resend.Email = await resend.Emails.get_async( + email_id=email_non_idempotent["id"] + ) + print(f"Retrieved email: {email_resp['id']}") + print("Email ID: ", email_resp["id"]) + print("Email from: ", email_resp["from"]) + print("Email to: ", email_resp["to"]) + print("Email subject: ", email_resp["subject"]) + print("Email html: ", email_resp["html"]) + print("Email created_at: ", email_resp["created_at"]) + print("Email reply_to: ", email_resp["reply_to"]) + print("Email bcc: ", email_resp["bcc"]) + print("Email cc: ", email_resp["cc"]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pytest.ini b/pytest.ini index fab84a5..c65eba7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,5 @@ [pytest] +asyncio_mode = auto log_file = logs/pytest.log log_file_level = DEBUG log_format = %(asctime)s %(levelname)s %(message)s diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f554545 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +httpx>=0.24.0 diff --git a/resend/__init__.py b/resend/__init__.py index 0db62da..a013ea1 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -1,4 +1,5 @@ import os +from typing import Union from .api_keys._api_key import ApiKey from .api_keys._api_keys import ApiKeys @@ -26,6 +27,8 @@ from .emails._receiving import Receiving as EmailsReceiving from .emails._tag import Tag from .http_client import HTTPClient +from .http_client_async import \ + AsyncHTTPClient # Okay to import AsyncHTTPClient since it is just an interface. from .http_client_requests import RequestsClient from .request import Request from .segments._segment import Segment @@ -39,12 +42,18 @@ WebhookHeaders, WebhookStatus) from .webhooks._webhooks import Webhooks +# Type for clients that support both sync and async +ResendHTTPClient = Union[HTTPClient, AsyncHTTPClient] + +# This is the client that is set by default HTTP Client +# But this can be overridden by the user and set to an async client. +default_http_client: ResendHTTPClient = RequestsClient() + + # Config vars api_key = os.environ.get("RESEND_API_KEY") api_url = os.environ.get("RESEND_API_URL", "https://api.resend.com") -# HTTP Client -default_http_client: HTTPClient = RequestsClient() __all__ = [ "__version__", @@ -97,6 +106,17 @@ "EmailsReceiving", "EmailAttachments", "ContactsTopics", + # HTTP Clients + "HTTPClient", # Default HTTP Client "RequestsClient", ] + +# Add async exports if available +try: + from .async_request import AsyncRequest # noqa: F401 + from .http_client_httpx import HTTPXClient # noqa: F401 + + __all__.extend(["AsyncHTTPClient", "HTTPXClient", "AsyncRequest"]) +except ImportError: + pass diff --git a/resend/api_keys/_api_keys.py b/resend/api_keys/_api_keys.py index 30a2583..c6abe49 100644 --- a/resend/api_keys/_api_keys.py +++ b/resend/api_keys/_api_keys.py @@ -7,6 +7,12 @@ from resend.api_keys._api_key import ApiKey from resend.pagination_helper import PaginationHelper +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class ApiKeys: @@ -144,3 +150,59 @@ def remove(cls, api_key_id: str) -> None: # This would raise if failed request.Request[None](path=path, params={}, verb="delete").perform() return None + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateApiKeyResponse: + """ + Add a new API key to authenticate communications with Resend (async). + see more: https://resend.com/docs/api-reference/api-keys/create-api-key + + Args: + params (CreateParams): The API key creation parameters + + Returns: + CreateApiKeyResponse: The created API key response with id and token + """ + path = "/api-keys" + resp = await AsyncRequest[ApiKeys.CreateApiKeyResponse]( + 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 API keys for the authenticated user (async). + see more: https://resend.com/docs/api-reference/api-keys/list-api-keys + + Args: + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A list of API key objects + """ + base_path = "/api-keys" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[ApiKeys.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, api_key_id: str) -> None: + """ + Remove an existing API key (async). + see more: https://resend.com/docs/api-reference/api-keys/delete-api-key + + Args: + api_key_id (str): The ID of the API key to remove + + Returns: + None + """ + path = f"/api-keys/{api_key_id}" + + # This would raise if failed + await AsyncRequest[None](path=path, params={}, verb="delete").perform() + return None diff --git a/resend/async_request.py b/resend/async_request.py new file mode 100644 index 0000000..f45cead --- /dev/null +++ b/resend/async_request.py @@ -0,0 +1,111 @@ +import json +from typing import Any, Dict, Generic, List, Optional, Union, cast + +from typing_extensions import Literal, TypeVar + +import resend +from resend.exceptions import (NoContentError, ResendError, + raise_for_code_and_type) +from resend.http_client_async import AsyncHTTPClient +from resend.version import get_version + +RequestVerb = Literal["get", "post", "put", "patch", "delete"] +T = TypeVar("T") + +ParamsType = Union[Dict[str, Any], List[Dict[str, Any]]] +HeadersType = Dict[str, str] + + +class AsyncRequest(Generic[T]): + def __init__( + self, + path: str, + params: ParamsType, + verb: RequestVerb, + options: Optional[Dict[str, Any]] = None, + ): + self.path = path + self.params = params + self.verb = verb + self.options = options + + async def perform(self) -> Union[T, None]: + data = await self.make_request(url=f"{resend.api_url}{self.path}") + + if isinstance(data, dict) and data.get("statusCode") not in (None, 200): + raise_for_code_and_type( + code=data.get("statusCode") or 500, + message=data.get("message", "Unknown error"), + error_type=data.get("name", "InternalServerError"), + ) + + return cast(T, data) + + async def perform_with_content(self) -> T: + resp = await self.perform() + if resp is None: + raise NoContentError() + return resp + + def __get_headers(self) -> HeadersType: + headers: HeadersType = { + "Accept": "application/json", + "Authorization": f"Bearer {resend.api_key}", + "User-Agent": f"resend-python:{get_version()}", + } + + if self.verb == "post" and self.options and "idempotency_key" in self.options: + headers["Idempotency-Key"] = str(self.options["idempotency_key"]) + + return headers + + async def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: + headers = self.__get_headers() + + if isinstance(self.params, dict): + json_params: Optional[Union[Dict[str, Any], List[Any]]] = { + str(k): v for k, v in self.params.items() + } + elif isinstance(self.params, list): + json_params = [dict(item) for item in self.params] + else: + json_params = None + + try: + # Cast to AsyncHTTPClient for type checking - user must set HTTPXClient + async_client = cast(AsyncHTTPClient, resend.default_http_client) + content, _status_code, resp_headers = await async_client.request( + method=self.verb, + url=url, + headers=headers, + json=json_params, + ) + + # Safety net around the HTTP Client + except Exception as e: + raise ResendError( + code=500, + message=str(e), + error_type="HttpClientError", + suggested_action="Request failed, please try again.", + ) + + content_type = {k.lower(): v for k, v in resp_headers.items()}.get( + "content-type", "" + ) + + if "application/json" not in content_type: + raise_for_code_and_type( + code=500, + message=f"Expected JSON response but got: {content_type}", + error_type="InternalServerError", + ) + + try: + return cast(Union[Dict[str, Any], List[Any]], json.loads(content)) + except json.JSONDecodeError: + raise_for_code_and_type( + code=500, + message="Failed to decode JSON response", + error_type="InternalServerError", + ) diff --git a/resend/broadcasts/_broadcasts.py b/resend/broadcasts/_broadcasts.py index 748e4ff..ca4352b 100644 --- a/resend/broadcasts/_broadcasts.py +++ b/resend/broadcasts/_broadcasts.py @@ -8,6 +8,12 @@ from ._broadcast import Broadcast +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + # _CreateParamsFrom is declared with functional TypedDict syntax here because # "from" is a reserved keyword in Python, and this is the best way to # support type-checking for it. @@ -371,3 +377,113 @@ def remove(cls, id: str) -> RemoveResponse: path=path, params={}, verb="delete" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateResponse: + """ + Create a broadcast (async). + see more: https://resend.com/docs/api-reference/broadcasts/create-broadcast + + Args: + params (CreateParams): The broadcast creation parameters + + Returns: + CreateResponse: The new broadcast object response + """ + path = "/broadcasts" + resp = await AsyncRequest[Broadcasts.CreateResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateResponse: + """ + Update a broadcast (async). + see more: https://resend.com/docs/api-reference/broadcasts/update-broadcast + + Args: + params (UpdateParams): The broadcast update parameters + + Returns: + UpdateResponse: The updated broadcast object response + """ + path = f"/broadcasts/{params['broadcast_id']}" + resp = await AsyncRequest[Broadcasts.UpdateResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def send_async(cls, params: SendParams) -> SendResponse: + """ + Sends a broadcast (async). + see more: https://resend.com/docs/api-reference/broadcasts/send-broadcast + + Args: + params (SendParams): The broadcast send parameters + + Returns: + SendResponse: The new broadcast object response + """ + path = f"/broadcasts/{params['broadcast_id']}/send" + resp = await AsyncRequest[Broadcasts.SendResponse]( + 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 broadcasts (async). + see more: https://resend.com/docs/api-reference/broadcasts/list-broadcasts + + Args: + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A list of broadcast objects + """ + base_path = "/broadcasts" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Broadcasts.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, id: str) -> Broadcast: + """ + Retrieve a single broadcast (async). + see more: https://resend.com/docs/api-reference/broadcasts/get-broadcast + + Args: + id (str): The broadcast ID + + Returns: + Broadcast: The broadcast object + """ + path = f"/broadcasts/{id}" + resp = await AsyncRequest[Broadcast]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, id: str) -> RemoveResponse: + """ + Delete a single broadcast (async). + see more: https://resend.com/docs/api-reference/broadcasts/delete-broadcasts + + Args: + id (str): The broadcast ID + + Returns: + RemoveResponse: The remove response object + """ + path = f"/broadcasts/{id}" + resp = await AsyncRequest[Broadcasts.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/contact_properties/_contact_properties.py b/resend/contact_properties/_contact_properties.py index b649e98..bd8a961 100644 --- a/resend/contact_properties/_contact_properties.py +++ b/resend/contact_properties/_contact_properties.py @@ -6,6 +6,12 @@ from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + from ._contact_property import ContactProperty @@ -261,3 +267,96 @@ def remove(cls, id: str) -> RemoveResponse: path=path, params={}, verb="delete" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateResponse: + """ + Create a new contact property (async). + see more: https://resend.com/docs/api-reference/contact-properties/create-contact-property + + Args: + params (CreateParams): The contact property creation parameters + + Returns: + CreateResponse: The created contact property response + """ + path = "/contact-properties" + resp = await AsyncRequest[ContactProperties.CreateResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, id: str) -> ContactProperty: + """ + Get a contact property by ID (async). + see more: https://resend.com/docs/api-reference/contact-properties/get-contact-property + + Args: + id (str): The contact property ID + + Returns: + ContactProperty: The contact property object + """ + path = f"/contact-properties/{id}" + resp = await AsyncRequest[ContactProperty]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + List all contact properties (async). + see more: https://resend.com/docs/api-reference/contact-properties/list-contact-properties + + Args: + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A list of contact property objects + """ + base_path = "/contact-properties" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[ContactProperties.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateResponse: + """ + Update an existing contact property (async). + see more: https://resend.com/docs/api-reference/contact-properties/update-contact-property + + Args: + params (UpdateParams): The contact property update parameters + + Returns: + UpdateResponse: The updated contact property response + """ + path = f"/contact-properties/{params['id']}" + payload: Dict[str, Any] = {"fallback_value": params["fallback_value"]} + resp = await AsyncRequest[ContactProperties.UpdateResponse]( + path=path, params=payload, verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, id: str) -> RemoveResponse: + """ + Remove a contact property by ID (async). + see more: https://resend.com/docs/api-reference/contact-properties/delete-contact-property + + Args: + id (str): The contact property ID + + Returns: + RemoveResponse: The removed contact property response object + """ + path = f"/contact-properties/{id}" + resp = await AsyncRequest[ContactProperties.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/contacts/_contacts.py b/resend/contacts/_contacts.py index e166321..b35b643 100644 --- a/resend/contacts/_contacts.py +++ b/resend/contacts/_contacts.py @@ -10,6 +10,12 @@ from ._topics import Topics from .segments._contact_segments import ContactSegments +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class Contacts: # Sub-API for managing contact-segment associations @@ -22,6 +28,7 @@ class RemoveContactResponse(BaseResponse): Attributes: object (str): 'contact' + id (str): The ID of the removed contact contact (str): The ID of the removed contact deleted (bool): Whether the contact was deleted """ @@ -30,6 +37,10 @@ class RemoveContactResponse(BaseResponse): """ The object type: contact """ + id: str + """ + The ID of the removed contact. + """ contact: str """ The ID of the removed contact. @@ -344,3 +355,161 @@ def remove( path=path, params={}, verb="delete" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateContactResponse: + """ + Create a new contact (async). + Can create either a global contact or an audience-specific contact. + see more: https://resend.com/docs/api-reference/contacts/create-contact + + Args: + params (CreateParams): The contact creation parameters + - If audience_id is provided: creates audience-specific contact + - If audience_id is omitted: creates global contact with optional properties field + + Returns: + CreateContactResponse: The created contact response + """ + audience_id = params.get("audience_id") + + if audience_id: + path = f"/audiences/{audience_id}/contacts" + else: + path = "/contacts" + + resp = await AsyncRequest[Contacts.CreateContactResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateContactResponse: + """ + Update an existing contact (async). + Can update either a global contact or an audience-specific contact. + see more: https://resend.com/docs/api-reference/contacts/update-contact + + Args: + params (UpdateParams): The contact update parameters + - If audience_id is provided: updates audience-specific contact + - If audience_id is omitted: updates global contact with optional properties field + + Returns: + UpdateContactResponse: The updated contact response. + """ + if params.get("id") is None and params.get("email") is None: + raise ValueError("id or email must be provided") + + # Email takes precedence over id (matching Node.js behavior) + contact_identifier = ( + params.get("email") if params.get("email") is not None else params.get("id") + ) + audience_id = params.get("audience_id") + + if audience_id: + path = f"/audiences/{audience_id}/contacts/{contact_identifier}" + else: + path = f"/contacts/{contact_identifier}" + + resp = await AsyncRequest[Contacts.UpdateContactResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def list_async( + cls, audience_id: Optional[str] = None, params: Optional[ListParams] = None + ) -> ListResponse: + """ + List all contacts (async). + Can list either global contacts or audience-specific contacts. + see more: https://resend.com/docs/api-reference/contacts/list-contacts + + Args: + audience_id (Optional[str]): The audience ID. If not provided, lists all global contacts. + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A list of contact objects + """ + if audience_id: + base_path = f"/audiences/{audience_id}/contacts" + else: + base_path = "/contacts" + + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Contacts.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async( + cls, + audience_id: Optional[str] = None, + id: Optional[str] = None, + email: Optional[str] = None, + ) -> Contact: + """ + Get a contact (async). + Can retrieve either a global contact or an audience-specific contact. + see more: https://resend.com/docs/api-reference/contacts/get-contact + + Args: + audience_id (Optional[str]): The audience ID. If not provided, retrieves global contact. + id (Optional[str]): The contact ID. Either id or email must be provided. + email (Optional[str]): The contact email. Either id or email must be provided. + + Returns: + Contact: The contact object + """ + # Email takes precedence over id (matching Node.js behavior) + contact_identifier = email if email is not None else id + if contact_identifier is None: + raise ValueError("id or email must be provided") + + if audience_id: + path = f"/audiences/{audience_id}/contacts/{contact_identifier}" + else: + path = f"/contacts/{contact_identifier}" + + resp = await AsyncRequest[Contact]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async( + cls, + audience_id: Optional[str] = None, + id: Optional[str] = None, + email: Optional[str] = None, + ) -> RemoveContactResponse: + """ + Remove a contact by ID or by Email (async). + Can remove either a global contact or an audience-specific contact. + see more: https://resend.com/docs/api-reference/contacts/delete-contact + + Args: + audience_id (Optional[str]): The audience ID. If not provided, removes global contact. + id (Optional[str]): The contact ID + email (Optional[str]): The contact email + + Returns: + RemoveContactResponse: The removed contact response object + """ + contact_identifier = email if email is not None else id + if contact_identifier is None: + raise ValueError("id or email must be provided") + + if audience_id: + path = f"/audiences/{audience_id}/contacts/{contact_identifier}" + else: + path = f"/contacts/{contact_identifier}" + + resp = await AsyncRequest[Contacts.RemoveContactResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/contacts/_topics.py b/resend/contacts/_topics.py index d178e04..2321885 100644 --- a/resend/contacts/_topics.py +++ b/resend/contacts/_topics.py @@ -6,6 +6,12 @@ from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + from ._contact_topic import ContactTopic, TopicSubscriptionUpdate @@ -178,3 +184,69 @@ def update(cls, params: UpdateParams) -> UpdateResponse: path=path, params=request_body, verb="patch" ).perform_with_content() return resp + + @classmethod + async def list_async( + cls, + contact_id: Optional[str] = None, + email: Optional[str] = None, + params: Optional["Topics.ListParams"] = None, + ) -> "Topics.ListResponse": + """ + List all topics for a contact (async). + see more: https://resend.com/docs/api-reference/contacts/get-contact-topics + + Args: + contact_id (Optional[str]): The contact ID + email (Optional[str]): The contact email + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A list of contact topic objects + + Raises: + ValueError: If neither contact_id nor email is provided + """ + contact = email if contact_id is None else contact_id + if contact is None: + raise ValueError("contact_id or email must be provided") + + base_path = f"/contacts/{contact}/topics" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[_ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateResponse: + """ + Update topic subscriptions for a contact (async). + see more: https://resend.com/docs/api-reference/contacts/update-contact-topics + + Args: + params (UpdateParams): The topic update parameters + + Returns: + UpdateResponse: The updated contact response + + Raises: + ValueError: If neither id nor email is provided in params + """ + if params.get("id") is None and params.get("email") is None: + raise ValueError("id or email must be provided") + + contact = ( + params.get("id") if params.get("id") is not None else params.get("email") + ) + path = f"/contacts/{contact}/topics" + + request_body: Union[Dict[str, Any], List[Dict[str, Any]]] = cast( + List[Dict[str, Any]], params["topics"] + ) + + resp = await AsyncRequest[_UpdateResponse]( + path=path, params=request_body, verb="patch" + ).perform_with_content() + return resp diff --git a/resend/contacts/segments/_contact_segments.py b/resend/contacts/segments/_contact_segments.py index f0dae00..ce564a0 100644 --- a/resend/contacts/segments/_contact_segments.py +++ b/resend/contacts/segments/_contact_segments.py @@ -6,6 +6,12 @@ from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + from ._contact_segment import ContactSegment @@ -238,3 +244,82 @@ def list( path=path, params={}, verb="get" ).perform_with_content() return resp + + @classmethod + async def add_async(cls, params: AddParams) -> AddContactSegmentResponse: + """ + Add a contact to a segment (async). + + Args: + params (AddParams): Parameters including segment_id and either contact_id or email + + Returns: + AddContactSegmentResponse: The response containing the association ID + + Raises: + ValueError: If neither contact_id nor email is provided + """ + contact_identifier = params.get("email") or params.get("contact_id") + if not contact_identifier: + raise ValueError("Either contact_id or email must be provided") + + segment_id = params["segment_id"] + path = f"/contacts/{contact_identifier}/segments/{segment_id}" + resp = await AsyncRequest[ContactSegments.AddContactSegmentResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, params: RemoveParams) -> RemoveContactSegmentResponse: + """ + Remove a contact from a segment (async). + + Args: + params (RemoveParams): Parameters including segment_id and either contact_id or email + + Returns: + RemoveContactSegmentResponse: The response containing the deleted status + + Raises: + ValueError: If neither contact_id nor email is provided + """ + contact_identifier = params.get("email") or params.get("contact_id") + if not contact_identifier: + raise ValueError("Either contact_id or email must be provided") + + segment_id = params["segment_id"] + path = f"/contacts/{contact_identifier}/segments/{segment_id}" + resp = await AsyncRequest[ContactSegments.RemoveContactSegmentResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def list_async( + cls, params: ListParams, pagination: Optional[ListContactSegmentsParams] = None + ) -> ListContactSegmentsResponse: + """ + List all segments for a contact (async). + + Args: + params (ListParams): Parameters containing either contact_id or email + pagination (Optional[ListContactSegmentsParams]): Optional pagination parameters + + Returns: + ListContactSegmentsResponse: A list of segment objects + + Raises: + ValueError: If neither contact_id nor email is provided + """ + contact_identifier = params.get("email") or params.get("contact_id") + if not contact_identifier: + raise ValueError("Either contact_id or email must be provided") + + base_path = f"/contacts/{contact_identifier}/segments" + query_params = cast(Dict[Any, Any], pagination) if pagination else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[ContactSegments.ListContactSegmentsResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp diff --git a/resend/domains/_domains.py b/resend/domains/_domains.py index c1bf090..2ba0ba8 100644 --- a/resend/domains/_domains.py +++ b/resend/domains/_domains.py @@ -8,6 +8,12 @@ from resend.domains._record import Record from resend.pagination_helper import PaginationHelper +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + TlsOptions = Literal["enforced", "opportunistic"] @@ -247,3 +253,113 @@ def verify(cls, domain_id: str) -> Domain: path=path, params={}, verb="post" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateDomainResponse: + """ + Create a domain through the Resend Email API (async). + see more: https://resend.com/docs/api-reference/domains/create-domain + + Args: + params (CreateParams): The domain creation parameters + + Returns: + CreateDomainResponse: The created domain response + """ + path = "/domains" + resp = await AsyncRequest[Domains.CreateDomainResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> Domain: + """ + Update an existing domain (async). + see more: https://resend.com/docs/api-reference/domains/update-domain + + Args: + params (UpdateParams): The domain update parameters + + Returns: + Domain: The updated domain object + """ + path = f"/domains/{params['id']}" + resp = await AsyncRequest[Domain]( + path=path, params=cast(Dict[Any, Any], params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, domain_id: str) -> Domain: + """ + Retrieve a single domain for the authenticated user (async). + see more: https://resend.com/docs/api-reference/domains/get-domain + + Args: + domain_id (str): The domain ID + + Returns: + Domain: The domain object + """ + path = f"/domains/{domain_id}" + resp = await AsyncRequest[Domain]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of domains for the authenticated user (async). + see more: https://resend.com/docs/api-reference/domains/list-domains + + Args: + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A list of domain objects + """ + base_path = "/domains" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Domains.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, domain_id: str) -> Domain: + """ + Remove an existing domain (async). + see more: https://resend.com/docs/api-reference/domains/delete-domain + + Args: + domain_id (str): The domain ID + + Returns: + Domain: The removed domain object + """ + path = f"/domains/{domain_id}" + resp = await AsyncRequest[Domain]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def verify_async(cls, domain_id: str) -> Domain: + """ + Verify an existing domain (async). + see more: https://resend.com/docs/api-reference/domains/verify-domain + + Args: + domain_id (str): The domain ID + + Returns: + Domain: The verified domain object + """ + path = f"/domains/{domain_id}/verify" + resp = await AsyncRequest[Domain]( + path=path, params={}, verb="post" + ).perform_with_content() + return resp diff --git a/resend/emails/_attachments.py b/resend/emails/_attachments.py index a8568dd..d28fe13 100644 --- a/resend/emails/_attachments.py +++ b/resend/emails/_attachments.py @@ -8,6 +8,12 @@ EmailAttachmentDetails) from resend.pagination_helper import PaginationHelper +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class _ListParams(TypedDict): limit: NotRequired[int] @@ -107,3 +113,51 @@ def list(cls, email_id: str, params: Optional[ListParams] = None) -> ListRespons verb="get", ).perform_with_content() return resp + + @classmethod + async def get_async( + cls, email_id: str, attachment_id: str + ) -> EmailAttachmentDetails: + """ + Retrieve a single attachment from a sent email (async). + see more: https://resend.com/docs/api-reference/attachments/retrieve-sent-email-attachment + + Args: + email_id (str): The ID of the sent email + attachment_id (str): The ID of the attachment to retrieve + + Returns: + EmailAttachmentDetails: The attachment details including download URL + """ + path = f"/emails/{email_id}/attachments/{attachment_id}" + resp = await AsyncRequest[EmailAttachmentDetails]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp + + @classmethod + async def list_async( + cls, email_id: str, params: Optional[ListParams] = None + ) -> ListResponse: + """ + Retrieve a list of attachments from a sent email (async). + see more: https://resend.com/docs/api-reference/attachments/list-sent-email-attachments + + Args: + email_id (str): The ID of the sent email + params (Optional[ListParams]): The list parameters for pagination + + Returns: + ListResponse: A paginated list of attachment objects + """ + base_path = f"/emails/{email_id}/attachments" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Attachments.ListResponse]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp diff --git a/resend/emails/_batch.py b/resend/emails/_batch.py index f8d5f69..9aebc0c 100644 --- a/resend/emails/_batch.py +++ b/resend/emails/_batch.py @@ -7,6 +7,12 @@ from ._emails import Emails +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class SendEmailResponse(BaseResponse): id: str @@ -95,3 +101,28 @@ def send( options=cast(Dict[Any, Any], options), ).perform_with_content() return resp + + @classmethod + async def send_async( + cls, params: List[Emails.SendParams], options: Optional[SendOptions] = None + ) -> SendResponse: + """ + Trigger up to 100 batch emails at once (async). + see more: https://resend.com/docs/api-reference/emails/send-batch-emails + + Args: + params (List[Emails.SendParams]): The list of emails to send + options (Optional[SendOptions]): Batch options, ie: idempotency_key + + Returns: + SendResponse: A list of email objects + """ + path = "/emails/batch" + + resp = await AsyncRequest[Batch.SendResponse]( + path=path, + params=cast(List[Dict[Any, Any]], params), + verb="post", + options=cast(Dict[Any, Any], options), + ).perform_with_content() + return resp diff --git a/resend/emails/_emails.py b/resend/emails/_emails.py index 9f7416a..ee981c3 100644 --- a/resend/emails/_emails.py +++ b/resend/emails/_emails.py @@ -11,6 +11,12 @@ from resend.emails._tag import Tag from resend.pagination_helper import PaginationHelper +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class EmailTemplate(TypedDict): """ @@ -361,3 +367,109 @@ def list(cls, params: Optional[ListParams] = None) -> ListResponse: verb="get", ).perform_with_content() return resp + + @classmethod + async def send_async( + cls, params: SendParams, options: Optional[SendOptions] = None + ) -> SendResponse: + """ + Send an email through the Resend Email API (async version). + see more: https://resend.com/docs/api-reference/emails/send-email + + Args: + params (SendParams): The email parameters + options (SendOptions): The email options + + Returns: + SendResponse: The send response with the email ID + """ + path = "/emails" + resp = await AsyncRequest[Emails.SendResponse]( + path=path, + params=cast(Dict[Any, Any], params), + verb="post", + options=cast(Dict[Any, Any], options), + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, email_id: str) -> Email: + """ + Retrieve a single email (async version). + see more: https://resend.com/docs/api-reference/emails/retrieve-email + + Args: + email_id (str): The ID of the email to retrieve + + Returns: + Email: The email object that was retrieved + """ + path = f"/emails/{email_id}" + resp = await AsyncRequest[Email]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of emails (async version). + see more: https://resend.com/docs/api-reference/emails/list-emails + + Args: + params (Optional[ListParams]): The list parameters for pagination + + Returns: + ListResponse: A paginated list of email objects + """ + base_path = "/emails" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Emails.ListResponse]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp + + @classmethod + async def cancel_async(cls, email_id: str) -> CancelScheduledEmailResponse: + """ + Cancel a scheduled email (async version). + see more: https://resend.com/docs/api-reference/emails/cancel-email + + Args: + email_id (str): The ID of the scheduled email to cancel + + Returns: + CancelScheduledEmailResponse: The response object that contains the ID of the scheduled email that was canceled + """ + path = f"/emails/{email_id}/cancel" + resp = await AsyncRequest[_CancelScheduledEmailResponse]( + path=path, + params={}, + verb="post", + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateEmailResponse: + """ + Update an email (async version). + see more: https://resend.com/docs/api-reference/emails/update-email + + Args: + params (UpdateParams): The email parameters to update + + Returns: + Email: The email object that was updated + """ + path = f"/emails/{params['id']}" + resp = await AsyncRequest[_UpdateEmailResponse]( + path=path, + params=cast(Dict[Any, Any], params), + verb="patch", + ).perform_with_content() + return resp diff --git a/resend/emails/_receiving.py b/resend/emails/_receiving.py index 97c98d3..cac815e 100644 --- a/resend/emails/_receiving.py +++ b/resend/emails/_receiving.py @@ -9,6 +9,12 @@ ListReceivedEmail, ReceivedEmail) from resend.pagination_helper import PaginationHelper +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class _ListParams(TypedDict): limit: NotRequired[int] @@ -148,6 +154,56 @@ def list( ).perform_with_content() return resp + @classmethod + async def get_async( + cls, email_id: str, attachment_id: str + ) -> EmailAttachmentDetails: + """ + Retrieve a single attachment from a received email (async). + see more: https://resend.com/docs/api-reference/attachments/retrieve-received-email-attachment + + Args: + email_id (str): The ID of the received email + attachment_id (str): The ID of the attachment to retrieve + + Returns: + EmailAttachmentDetails: The attachment details including download URL + """ + path = f"/emails/receiving/{email_id}/attachments/{attachment_id}" + resp = await AsyncRequest[EmailAttachmentDetails]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp + + @classmethod + async def list_async( + cls, + email_id: str, + params: Optional["Receiving.Attachments.ListParams"] = None, + ) -> "Receiving.Attachments.ListResponse": + """ + Retrieve a list of attachments from a received email (async). + see more: https://resend.com/docs/api-reference/attachments/list-received-email-attachments + + Args: + email_id (str): The ID of the received email + params (Optional[ListParams]): The list parameters for pagination + + Returns: + ListResponse: A paginated list of attachment objects + """ + base_path = f"/emails/receiving/{email_id}/attachments" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[_AttachmentListResponse]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp + class ListParams(_ListParams): """ ListParams is the class that wraps the parameters for the list method. @@ -209,3 +265,45 @@ def list(cls, params: Optional[ListParams] = None) -> ListResponse: verb="get", ).perform_with_content() return resp + + @classmethod + async def get_async(cls, email_id: str) -> ReceivedEmail: + """ + Retrieve a single received email (async). + see more: https://resend.com/docs/api-reference/emails/retrieve-received-email + + Args: + email_id (str): The ID of the received email to retrieve + + Returns: + ReceivedEmail: The received email object + """ + path = f"/emails/receiving/{email_id}" + resp = await AsyncRequest[ReceivedEmail]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of received emails (async). + see more: https://resend.com/docs/api-reference/emails/list-received-emails + + Args: + params (Optional[ListParams]): The list parameters for pagination + + Returns: + ListResponse: A paginated list of received email objects + """ + base_path = "/emails/receiving" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Receiving.ListResponse]( + path=path, + params={}, + verb="get", + ).perform_with_content() + return resp diff --git a/resend/http_client_async.py b/resend/http_client_async.py new file mode 100644 index 0000000..4df324d --- /dev/null +++ b/resend/http_client_async.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Mapping, Optional, Tuple, Union + + +class AsyncHTTPClient(ABC): + """ + Abstract base class for async HTTP clients. + This class defines the interface for making async HTTP requests. + Subclasses should implement the `request` method. + """ + + @abstractmethod + async def request( + self, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[Dict[str, object], List[object]]] = None, + ) -> Tuple[bytes, int, Mapping[str, str]]: + pass diff --git a/resend/http_client_httpx.py b/resend/http_client_httpx.py new file mode 100644 index 0000000..451096c --- /dev/null +++ b/resend/http_client_httpx.py @@ -0,0 +1,40 @@ +from typing import Dict, List, Mapping, Optional, Tuple, Union + +from resend.http_client_async import AsyncHTTPClient + +try: + import httpx +except ImportError: + raise ImportError( + "httpx is required for async support. Install it with: pip install resend[async]" + ) + + +class HTTPXClient(AsyncHTTPClient): + """ + Async HTTP client implementation using the httpx library. + """ + + def __init__(self, timeout: int = 30): + self._timeout = timeout + + async def request( + self, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[Dict[str, object], List[object]]] = None, + ) -> Tuple[bytes, int, Mapping[str, str]]: + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.request( + method=method, + url=url, + headers=headers, + json=json, + ) + return resp.content, resp.status_code, resp.headers + except httpx.RequestError as e: + # This gets caught by the async request.perform() method + # and raises a ResendError with the error type "HttpClientError" + raise RuntimeError(f"Request failed: {e}") from e diff --git a/resend/request.py b/resend/request.py index 5d8ddf3..e6fc2de 100644 --- a/resend/request.py +++ b/resend/request.py @@ -79,7 +79,12 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: json_params = None try: - content, _status_code, resp_headers = resend.default_http_client.request( + # Cast to HTTPClient for type checking - sync context expects sync client + from resend.http_client import HTTPClient + + sync_client = cast(HTTPClient, resend.default_http_client) + + content, _status_code, resp_headers = sync_client.request( method=self.verb, url=url, headers=headers, diff --git a/resend/segments/_segments.py b/resend/segments/_segments.py index 3f2dbfa..6793798 100644 --- a/resend/segments/_segments.py +++ b/resend/segments/_segments.py @@ -8,6 +8,12 @@ from ._segment import Segment +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + class Segments: @@ -182,3 +188,77 @@ def remove(cls, id: str) -> RemoveSegmentResponse: path=path, params={}, verb="delete" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateSegmentResponse: + """ + Create a segment (async). + see more: https://resend.com/docs/api-reference/segments/create-segment + + Args: + params (CreateParams): The segment creation parameters + + Returns: + CreateSegmentResponse: The created segment response + """ + path = "/segments" + resp = await AsyncRequest[Segments.CreateSegmentResponse]( + 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 segments (async). + see more: https://resend.com/docs/api-reference/segments/list-segments + + Args: + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A list of segment objects + """ + base_path = "/segments" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Segments.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, id: str) -> Segment: + """ + Retrieve a single segment (async). + see more: https://resend.com/docs/api-reference/segments/get-segment + + Args: + id (str): The segment ID + + Returns: + Segment: The segment object + """ + path = f"/segments/{id}" + resp = await AsyncRequest[Segment]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, id: str) -> RemoveSegmentResponse: + """ + Delete a single segment (async). + see more: https://resend.com/docs/api-reference/segments/delete-segment + + Args: + id (str): The segment ID + + Returns: + RemoveSegmentResponse: The removed segment response + """ + path = f"/segments/{id}" + resp = await AsyncRequest[Segments.RemoveSegmentResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/templates/_templates.py b/resend/templates/_templates.py index 3532d44..868bb81 100644 --- a/resend/templates/_templates.py +++ b/resend/templates/_templates.py @@ -8,6 +8,12 @@ from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + from ._template import Template, TemplateListItem, Variable # Use functional TypedDict syntax to support reserved keyword "from" @@ -341,3 +347,119 @@ def remove(cls, template_id: str) -> RemoveResponse: path=path, params={}, verb="delete" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateResponse: + """Create a new template (async). + + Args: + params: The template creation parameters. + + Returns: + CreateResponse: The created template response with ID and object type. + """ + path = "/templates" + resp = await AsyncRequest[Templates.CreateResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, template_id: str) -> Template: + """Retrieve a template by ID (async). + + Args: + template_id: The Template ID. + + Returns: + Template: The template object. + """ + path = f"/templates/{template_id}" + resp = await AsyncRequest[Template]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """List all templates with pagination support (async). + + Args: + params: Optional pagination parameters (limit, after, before). + + Returns: + ListResponse: The paginated list of templates. + """ + base_path = "/templates" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Templates.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateResponse: + """Update an existing template (async). + + Args: + params: The template update parameters (must include id). + + Returns: + UpdateResponse: The updated template response with ID and object type. + """ + template_id = params["id"] + path = f"/templates/{template_id}" + update_params = {k: v for k, v in params.items() if k != "id"} + resp = await AsyncRequest[Templates.UpdateResponse]( + path=path, params=cast(Dict[Any, Any], update_params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def publish_async(cls, template_id: str) -> PublishResponse: + """Publish a template to make it available for use (async). + + Args: + template_id: The Template ID. + + Returns: + PublishResponse: The published template response with ID and object type. + """ + path = f"/templates/{template_id}/publish" + resp = await AsyncRequest[Templates.PublishResponse]( + path=path, params={}, verb="post" + ).perform_with_content() + return resp + + @classmethod + async def duplicate_async(cls, template_id: str) -> DuplicateResponse: + """Duplicate a template (async). + + Args: + template_id: The Template ID to duplicate. + + Returns: + DuplicateResponse: The duplicated template response with new ID and object type. + """ + path = f"/templates/{template_id}/duplicate" + resp = await AsyncRequest[Templates.DuplicateResponse]( + path=path, params={}, verb="post" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, template_id: str) -> RemoveResponse: + """Delete a template (async). + + Args: + template_id: The Template ID. + + Returns: + RemoveResponse: The deletion response with ID, object type, and deleted status. + """ + path = f"/templates/{template_id}" + resp = await AsyncRequest[Templates.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/topics/_topics.py b/resend/topics/_topics.py index e724edc..8d05550 100644 --- a/resend/topics/_topics.py +++ b/resend/topics/_topics.py @@ -6,6 +6,12 @@ from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + from ._topic import Topic @@ -228,3 +234,96 @@ def list(cls, params: Optional[ListParams] = None) -> ListResponse: path=path, params={}, verb="get" ).perform_with_content() return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateTopicResponse: + """ + Create a topic (async). + see more: https://resend.com/docs/api-reference/topics/create-topic + + Args: + params (CreateParams): The topic creation parameters + + Returns: + CreateTopicResponse: The created topic response with the topic ID + """ + path = "/topics" + resp = await AsyncRequest[Topics.CreateTopicResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, id: str) -> Topic: + """ + Retrieve a single topic by its ID (async). + see more: https://resend.com/docs/api-reference/topics/get-topic + + Args: + id (str): The topic ID + + Returns: + Topic: The topic object + """ + path = f"/topics/{id}" + resp = await AsyncRequest[Topic]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, id: str, params: UpdateParams) -> UpdateTopicResponse: + """ + Update an existing topic (async). + see more: https://resend.com/docs/api-reference/topics/update-topic + + Args: + id (str): The topic ID + params (UpdateParams): The topic update parameters + + Returns: + UpdateTopicResponse: The updated topic response with the topic ID + """ + path = f"/topics/{id}" + resp = await AsyncRequest[Topics.UpdateTopicResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, id: str) -> RemoveTopicResponse: + """ + Delete a single topic (async). + see more: https://resend.com/docs/api-reference/topics/delete-topic + + Args: + id (str): The topic ID + + Returns: + RemoveTopicResponse: The removed topic response + """ + path = f"/topics/{id}" + resp = await AsyncRequest[Topics.RemoveTopicResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of topics (async). + see more: https://resend.com/docs/api-reference/topics/list-topics + + Args: + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A list of topic objects + """ + base_path = "/topics" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Topics.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp diff --git a/resend/webhooks/_webhooks.py b/resend/webhooks/_webhooks.py index 1abf0ea..3fe6674 100644 --- a/resend/webhooks/_webhooks.py +++ b/resend/webhooks/_webhooks.py @@ -9,6 +9,12 @@ from resend import request from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass from resend.webhooks._webhook import (VerifyWebhookOptions, Webhook, WebhookEvent, WebhookStatus) @@ -348,6 +354,99 @@ def verify(cls, options: VerifyWebhookOptions) -> None: raise ValueError("no matching signature found") + @classmethod + async def create_async(cls, params: CreateParams) -> CreateWebhookResponse: + """ + Create a webhook (async). + see more: https://resend.com/docs/api-reference/webhooks/create-webhook + + Args: + params (CreateParams): The webhook creation parameters + + Returns: + CreateWebhookResponse: The created webhook response with id and signing_secret + """ + path = "/webhooks" + resp = await AsyncRequest[Webhooks.CreateWebhookResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, webhook_id: str) -> Webhook: + """ + Retrieve a single webhook (async). + see more: https://resend.com/docs/api-reference/webhooks/get-webhook + + Args: + webhook_id (str): The webhook ID + + Returns: + Webhook: The webhook object + """ + path = f"/webhooks/{webhook_id}" + resp = await AsyncRequest[Webhook]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateWebhookResponse: + """ + Update an existing webhook configuration (async). + see more: https://resend.com/docs/api-reference/webhooks/update-webhook + + Args: + params (UpdateParams): The webhook update parameters + + Returns: + UpdateWebhookResponse: The updated webhook response with id + """ + webhook_id = params["webhook_id"] + path = f"/webhooks/{webhook_id}" + resp = await AsyncRequest[Webhooks.UpdateWebhookResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of webhooks (async). + see more: https://resend.com/docs/api-reference/webhooks/list-webhooks + + Args: + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A list of webhook objects + """ + base_path = "/webhooks" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[Webhooks.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, webhook_id: str) -> DeleteWebhookResponse: + """ + Remove an existing webhook (async). + see more: https://resend.com/docs/api-reference/webhooks/delete-webhook + + Args: + webhook_id (str): The webhook ID + + Returns: + DeleteWebhookResponse: The deleted webhook response + """ + path = f"/webhooks/{webhook_id}" + resp = await AsyncRequest[Webhooks.DeleteWebhookResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + @staticmethod def _generate_signature(secret: bytes, content: bytes) -> str: """ diff --git a/setup.py b/setup.py index 53f0587..f004035 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,9 @@ packages=find_packages(exclude=["tests", "tests.*"]), package_data={"resend": ["py.typed"]}, install_requires=install_requires, + extras_require={ + "async": ["httpx>=0.24.0"], + }, zip_safe=False, python_requires=">=3.7", keywords=["email", "email platform"], diff --git a/tests/api_keys_async_test.py b/tests/api_keys_async_test.py new file mode 100644 index 0000000..89610a5 --- /dev/null +++ b/tests/api_keys_async_test.py @@ -0,0 +1,69 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestResendApiKeysAsync(AsyncResendBaseTest): + async def test_api_keys_create_async(self) -> None: + self.set_mock_json( + { + "id": "dacf4072-4119-4d88-932f-6202748ac7c8", + "token": "re_c1tpEyD8_NKFusih9vKVQknRAQfmFcWCv", + } + ) + + params: resend.ApiKeys.CreateParams = { + "name": "prod", + } + key = await resend.ApiKeys.create_async(params) + assert key["id"] == "dacf4072-4119-4d88-932f-6202748ac7c8" + + async def test_should_create_api_key_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.ApiKeys.CreateParams = { + "name": "prod", + } + with pytest.raises(NoContentError): + _ = await resend.ApiKeys.create_async(params) + + async def test_api_keys_list_async(self) -> None: + self.set_mock_json( + { + "data": [ + { + "id": "91f3200a-df72-4654-b0cd-f202395f5354", + "name": "Production", + "created_at": "2023-04-08T00:11:13.110779+00:00", + } + ] + } + ) + + keys: resend.ApiKeys.ListResponse = await resend.ApiKeys.list_async() + for key in keys["data"]: + assert key["id"] == "91f3200a-df72-4654-b0cd-f202395f5354" + assert key["name"] == "Production" + assert key["created_at"] == "2023-04-08T00:11:13.110779+00:00" + + async def test_should_list_api_key_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.ApiKeys.list_async() + + async def test_api_keys_remove_async(self) -> None: + self.set_mock_text("") + + # Remove operation returns None, verify no exceptions raised + await resend.ApiKeys.remove_async( + api_key_id="4ef9a417-02e9-4d39-ad75-9611e0fcc33c", + ) diff --git a/tests/attachments_async_test.py b/tests/attachments_async_test.py new file mode 100644 index 0000000..ebd0578 --- /dev/null +++ b/tests/attachments_async_test.py @@ -0,0 +1,79 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestResendAttachmentsAsync(AsyncResendBaseTest): + async def test_sent_email_attachments_get_async(self) -> None: + self.set_mock_json( + { + "id": "2a0c9ce0-3112-4728-976e-47ddcd16a318", + "object": "attachment", + "filename": "avatar.png", + "content_type": "image/png", + "content_disposition": "inline", + "content_id": "img001", + "download_url": "https://cdn.resend.com/emails/test/attachments/test-id", + "expires_at": "2025-10-17T14:29:41.521Z", + } + ) + + attachment: resend.EmailAttachmentDetails = ( + await resend.Emails.Attachments.get_async( + email_id="4ef9a417-02e9-4d39-ad75-9611e0fcc33c", + attachment_id="2a0c9ce0-3112-4728-976e-47ddcd16a318", + ) + ) + assert attachment["id"] == "2a0c9ce0-3112-4728-976e-47ddcd16a318" + assert attachment["object"] == "attachment" + assert attachment["filename"] == "avatar.png" + + async def test_should_get_sent_email_attachment_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Emails.Attachments.get_async( + email_id="4ef9a417-02e9-4d39-ad75-9611e0fcc33c", + attachment_id="2a0c9ce0-3112-4728-976e-47ddcd16a318", + ) + + async def test_sent_email_attachments_list_async(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": "2a0c9ce0-3112-4728-976e-47ddcd16a318", + "filename": "avatar.png", + "content_type": "image/png", + "content_disposition": "inline", + "size": 1024, + } + ], + } + ) + + attachments = await resend.Emails.Attachments.list_async( + email_id="4ef9a417-02e9-4d39-ad75-9611e0fcc33c" + ) + assert attachments["object"] == "list" + assert attachments["has_more"] is False + assert len(attachments["data"]) == 1 + assert attachments["data"][0]["id"] == "2a0c9ce0-3112-4728-976e-47ddcd16a318" + + async def test_should_list_sent_email_attachments_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Emails.Attachments.list_async( + email_id="4ef9a417-02e9-4d39-ad75-9611e0fcc33c" + ) diff --git a/tests/audiences_async_test.py b/tests/audiences_async_test.py new file mode 100644 index 0000000..6003429 --- /dev/null +++ b/tests/audiences_async_test.py @@ -0,0 +1,112 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestResendSegmentsAsync(AsyncResendBaseTest): + async def test_segments_create_async(self) -> None: + self.set_mock_json( + { + "object": "audience", + "id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name": "Registered Users", + } + ) + + params: resend.Segments.CreateParams = { + "name": "Python SDK Segment", + } + segment = await resend.Segments.create_async(params) + assert segment["id"] == "78261eea-8f8b-4381-83c6-79fa7120f1cf" + assert segment["name"] == "Registered Users" + + async def test_should_create_segments_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Segments.CreateParams = { + "name": "Python SDK Segment", + } + with pytest.raises(NoContentError): + _ = await resend.Segments.create_async(params) + + async def test_segments_get_async(self) -> None: + self.set_mock_json( + { + "object": "audience", + "id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name": "Registered Users", + "created_at": "2023-10-06T22:59:55.977Z", + } + ) + + segment = await resend.Segments.get_async( + id="78261eea-8f8b-4381-83c6-79fa7120f1cf" + ) + assert segment["id"] == "78261eea-8f8b-4381-83c6-79fa7120f1cf" + assert segment["name"] == "Registered Users" + assert segment["created_at"] == "2023-10-06T22:59:55.977Z" + + async def test_should_get_segments_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Segments.get_async( + id="78261eea-8f8b-4381-83c6-79fa7120f1cf" + ) + + async def test_segments_remove_async(self) -> None: + self.set_mock_json( + { + "object": "audience", + "id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "deleted": True, + } + ) + + rmed = await resend.Segments.remove_async( + "78261eea-8f8b-4381-83c6-79fa7120f1cf" + ) + assert rmed["id"] == "78261eea-8f8b-4381-83c6-79fa7120f1cf" + assert rmed["deleted"] is True + + async def test_should_remove_segments_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Segments.remove_async( + id="78261eea-8f8b-4381-83c6-79fa7120f1cf" + ) + + async def test_segments_list_async(self) -> None: + self.set_mock_json( + { + "object": "list", + "data": [ + { + "id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name": "Registered Users", + "created_at": "2023-10-06T22:59:55.977Z", + } + ], + } + ) + + segments: resend.Segments.ListResponse = await resend.Segments.list_async() + assert segments["data"][0]["id"] == "78261eea-8f8b-4381-83c6-79fa7120f1cf" + assert segments["data"][0]["name"] == "Registered Users" + + async def test_should_list_segments_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Segments.list_async() diff --git a/tests/batch_emails_async_test.py b/tests/batch_emails_async_test.py new file mode 100644 index 0000000..3e4d6e4 --- /dev/null +++ b/tests/batch_emails_async_test.py @@ -0,0 +1,100 @@ +from typing import List + +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestResendBatchSendAsync(AsyncResendBaseTest): + async def test_batch_email_send_async(self) -> None: + self.set_mock_json( + { + "data": [ + {"id": "ae2014de-c168-4c61-8267-70d2662a1ce1"}, + {"id": "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb"}, + ] + } + ) + + params: List[resend.Emails.SendParams] = [ + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hey", + "html": "hello, world!", + }, + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hello", + "html": "hello, world!", + }, + ] + + emails: resend.Batch.SendResponse = await resend.Batch.send_async(params) + assert len(emails["data"]) == 2 + assert emails["data"][0]["id"] == "ae2014de-c168-4c61-8267-70d2662a1ce1" + assert emails["data"][1]["id"] == "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb" + + async def test_batch_email_send_async_with_options(self) -> None: + self.set_mock_json( + { + "data": [ + {"id": "ae2014de-c168-4c61-8267-70d2662a1ce1"}, + {"id": "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb"}, + ] + } + ) + + params: List[resend.Emails.SendParams] = [ + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hey", + "html": "hello, world!", + }, + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hello", + "html": "hello, world!", + }, + ] + + options: resend.Batch.SendOptions = { + "idempotency_key": "af477dc78aa9fa91fff3b8c0d4a2e1a5", + } + + emails: resend.Batch.SendResponse = await resend.Batch.send_async( + params, options=options + ) + assert len(emails["data"]) == 2 + assert emails["data"][0]["id"] == "ae2014de-c168-4c61-8267-70d2662a1ce1" + assert emails["data"][1]["id"] == "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb" + + async def test_should_send_batch_email_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: List[resend.Emails.SendParams] = [ + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hey", + "html": "hello, world!", + }, + { + "from": "from@resend.dev", + "to": ["to@resend.dev"], + "subject": "hello", + "html": "hello, world!", + }, + ] + with pytest.raises(NoContentError): + _ = await resend.Batch.send_async(params) diff --git a/tests/broadcasts_async_test.py b/tests/broadcasts_async_test.py new file mode 100644 index 0000000..c1e6f23 --- /dev/null +++ b/tests/broadcasts_async_test.py @@ -0,0 +1,204 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestResendBroadcastsAsync(AsyncResendBaseTest): + async def test_broadcasts_create_async(self) -> None: + self.set_mock_json({"id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}) + + params: resend.Broadcasts.CreateParams = { + "audience_id": "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e", + "from": "hi@example.com", + "subject": "Hello, world!", + "name": "Python SDK Broadcast", + } + broadcast: resend.Broadcasts.CreateResponse = ( + await resend.Broadcasts.create_async(params) + ) + assert broadcast["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" + + async def test_should_create_broadcasts_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Broadcasts.CreateParams = { + "audience_id": "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e", + "from": "hi@example.com", + "subject": "Hello, world!", + "name": "Python SDK Broadcast", + } + with pytest.raises(NoContentError): + _ = await resend.Broadcasts.create_async(params) + + async def test_broadcasts_update_async(self) -> None: + self.set_mock_json({"id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}) + + params: resend.Broadcasts.UpdateParams = { + "broadcast_id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794", + "audience_id": "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e", + "subject": "Hello, world! Updated!", + "name": "Python SDK Broadcast", + } + broadcast: resend.Broadcasts.UpdateResponse = ( + await resend.Broadcasts.update_async(params) + ) + assert broadcast["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" + + async def test_should_update_broadcasts_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + params: resend.Broadcasts.UpdateParams = { + "broadcast_id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794", + "audience_id": "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e", + "subject": "Hello, world! Updated!", + "name": "Python SDK Broadcast", + } + with pytest.raises(NoContentError): + _ = await resend.Broadcasts.update_async(params) + + async def test_broadcasts_get_async(self) -> None: + self.set_mock_json( + { + "object": "broadcast", + "id": "559ac32e-9ef5-46fb-82a1-b76b840c0f7b", + "name": "Announcements", + "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "from": "Acme