From 374b7943e5c8d5e8ef11d742bb90bed66ad77fd7 Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Fri, 9 May 2025 17:28:04 -0300 Subject: [PATCH 1/2] feat: idempotency key support for batch send --- examples/batch_email_send.py | 22 ++++++++++++++++++++-- resend/emails/_batch.py | 31 ++++++++++++++++++++++++++++--- tests/batch_emails_test.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/examples/batch_email_send.py b/examples/batch_email_send.py index 02ae6fa..75eaf23 100644 --- a/examples/batch_email_send.py +++ b/examples/batch_email_send.py @@ -24,10 +24,28 @@ ] try: + # Send batch emails + print("sending without idempotency_key") emails: resend.Batch.SendResponse = resend.Batch.send(params) for email in emails["data"]: print(f"Email id: {email['id']}") -except resend.exceptions.ResendError as e: +except resend.exceptions.ResendError as err: print("Failed to send batch emails") - print(f"Error: {e}") + 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 = resend.Batch.send(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) diff --git a/resend/emails/_batch.py b/resend/emails/_batch.py index 0c8c194..8095a5f 100644 --- a/resend/emails/_batch.py +++ b/resend/emails/_batch.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, cast +from typing import Any, Dict, List, NotRequired, Optional, cast from typing_extensions import TypedDict @@ -8,6 +8,15 @@ from ._emails import Emails +class _SendOptions(TypedDict): + idempotency_key: NotRequired[str] + """ + Unique key that ensures the same operation is not processed multiple times. + Allows for safe retries without duplicating operations. + If provided, will be sent as the `Idempotency-Key` header. + """ + + class _SendResponse(TypedDict): data: List[Email] """ @@ -17,6 +26,16 @@ class _SendResponse(TypedDict): class Batch: + class SendOptions(_SendOptions): + """ + SendOptions is the class that wraps the options for the batch send method. + + Attributes: + idempotency_key (NotRequired[str]): Unique key that ensures the same operation is not processed multiple times. + Allows for safe retries without duplicating operations. + If provided, will be sent as the `Idempotency-Key` header. + """ + class SendResponse(_SendResponse): """ SendResponse type that wraps a list of email objects @@ -26,13 +45,16 @@ class SendResponse(_SendResponse): """ @classmethod - def send(cls, params: List[Emails.SendParams]) -> SendResponse: + def send( + cls, params: List[Emails.SendParams], options: Optional[SendOptions] = None + ) -> SendResponse: """ Trigger up to 100 batch emails at once. 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 @@ -40,6 +62,9 @@ def send(cls, params: List[Emails.SendParams]) -> SendResponse: path = "/emails/batch" resp = request.Request[_SendResponse]( - path=path, params=cast(List[Dict[Any, Any]], params), verb="post" + 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/tests/batch_emails_test.py b/tests/batch_emails_test.py index 006a2a9..ced4fa3 100644 --- a/tests/batch_emails_test.py +++ b/tests/batch_emails_test.py @@ -38,6 +38,40 @@ def test_batch_email_send(self) -> None: assert emails["data"][0]["id"] == "ae2014de-c168-4c61-8267-70d2662a1ce1" assert emails["data"][1]["id"] == "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb" + def test_batch_email_send_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.Emails.SendOptions = { + "idempotency_key": "af477dc78aa9fa91fff3b8c0d4a2e1a5", + } + + emails: resend.Batch.SendResponse = resend.Batch.send(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" + def test_should_send_batch_email_raise_exception_when_no_content(self) -> None: self.set_mock_json(None) params: List[resend.Emails.SendParams] = [ From 483dcafeccf2feb3daae196e865f08032911f688 Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Fri, 9 May 2025 17:30:23 -0300 Subject: [PATCH 2/2] fix: typing --- resend/emails/_batch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resend/emails/_batch.py b/resend/emails/_batch.py index 8095a5f..a79c2f5 100644 --- a/resend/emails/_batch.py +++ b/resend/emails/_batch.py @@ -1,6 +1,6 @@ -from typing import Any, Dict, List, NotRequired, Optional, cast +from typing import Any, Dict, List, Optional, cast -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict from resend import request