Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/async/batch_email_send_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@


async def main() -> None:
params: List[resend.Emails.SendParams] = [
params: List[resend.Batch.SendParams] = [
{
"from": "onboarding@resend.dev",
"to": ["delivered@resend.dev"],
Expand Down
4 changes: 2 additions & 2 deletions examples/batch_email_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
raise EnvironmentError("RESEND_API_KEY is missing")


params: List[resend.Emails.SendParams] = [
params: List[resend.Batch.SendParams] = [
{
"from": "onboarding@resend.dev",
"to": ["delivered@resend.dev"],
Expand Down Expand Up @@ -55,7 +55,7 @@
print("sending with permissive validation mode")

# Example with some invalid emails to demonstrate error handling
mixed_params: List[resend.Emails.SendParams] = [
mixed_params: List[resend.Batch.SendParams] = [
{
"from": "onboarding@resend.dev",
"to": ["delivered@resend.dev"],
Expand Down
27 changes: 22 additions & 5 deletions resend/emails/_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from resend import request
from resend._base_response import BaseResponse

from ._emails import Emails
from ._emails import _BatchSendParamsDefault

# Async imports (optional - only available with pip install resend[async])
try:
Expand Down Expand Up @@ -42,6 +42,23 @@ class BatchValidationError(TypedDict):

class Batch:

class SendParams(_BatchSendParamsDefault):
"""SendParams is the class that wraps the parameters for each email in a batch send.

Attributes:
from (NotRequired[str]): The email address to send the email from.
to (Union[str, List[str]]): List of email addresses to send the email to.
subject (NotRequired[str]): The subject of the email.
bcc (NotRequired[Union[List[str], str]]): Bcc
cc (NotRequired[Union[List[str], str]]): Cc
reply_to (NotRequired[Union[List[str], str]]): Reply to
html (NotRequired[str]): The HTML content of the email.
text (NotRequired[str]): The text content of the email.
headers (NotRequired[Dict[str, str]]): Custom headers to be added to the email.
tags (NotRequired[List[Tag]]): List of tags to be added to the email.
template (NotRequired[EmailTemplate]): Template configuration for sending emails using predefined templates.
"""

class SendOptions(TypedDict):
"""
SendOptions is the class that wraps the options for the batch send method.
Expand Down Expand Up @@ -79,14 +96,14 @@ class SendResponse(BaseResponse):

@classmethod
def send(
cls, params: List[Emails.SendParams], options: Optional[SendOptions] = None
cls, params: List[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
params (List[SendParams]): The list of emails to send
options (Optional[SendOptions]): Batch options, including batch_validation mode

Returns:
Expand All @@ -104,14 +121,14 @@ def send(

@classmethod
async def send_async(
cls, params: List[Emails.SendParams], options: Optional[SendOptions] = None
cls, params: List[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
params (List[SendParams]): The list of emails to send
options (Optional[SendOptions]): Batch options, ie: idempotency_key

Returns:
Expand Down
45 changes: 45 additions & 0 deletions resend/emails/_emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,49 @@ class _SendParamsDefault(_SendParamsFrom):
"""


class _BatchSendParamsDefault(_SendParamsFrom):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The _BatchSendParamsDefault class duplicates ~10 fields with identical types and docstrings from _SendParamsDefault. If a common field like to, html, or tags ever changes its type signature or docstring, both classes need updating — a real maintenance trap.

Consider extracting a shared base (e.g., _SendParamsCommon) with the common fields so that _SendParamsDefault adds only attachments and scheduled_at, and _BatchSendParamsDefault inherits the rest without duplication.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At resend/emails/_emails.py, line 137:

<comment>The `_BatchSendParamsDefault` class duplicates ~10 fields with identical types and docstrings from `_SendParamsDefault`. If a common field like `to`, `html`, or `tags` ever changes its type signature or docstring, both classes need updating — a real maintenance trap.

Consider extracting a shared base (e.g., `_SendParamsCommon`) with the common fields so that `_SendParamsDefault` adds only `attachments` and `scheduled_at`, and `_BatchSendParamsDefault` inherits the rest without duplication.</comment>

<file context>
@@ -134,6 +134,49 @@ class _SendParamsDefault(_SendParamsFrom):
     """
 
 
+class _BatchSendParamsDefault(_SendParamsFrom):
+    to: Union[str, List[str]]
+    """
</file context>

to: Union[str, List[str]]
"""
List of email addresses to send the email to.
"""
subject: NotRequired[str]
"""
The subject of the email.
"""
bcc: NotRequired[Union[List[str], str]]
"""
Bcc
"""
cc: NotRequired[Union[List[str], str]]
"""
Cc
"""
reply_to: NotRequired[Union[List[str], str]]
"""
Reply to
"""
html: NotRequired[str]
"""
The HTML content of the email.
"""
text: NotRequired[str]
"""
The text content of the email.
"""
headers: NotRequired[Dict[str, str]]
"""
Custom headers to be added to the email.
"""
tags: NotRequired[List[Tag]]
"""
List of tags to be added to the email.
"""
template: NotRequired[EmailTemplate]
"""
Template configuration for sending emails using predefined templates.
"""


class Emails:
Attachments = Attachments
Receiving = Receiving
Expand Down Expand Up @@ -181,6 +224,8 @@ class SendParams(_SendParamsDefault):
headers (NotRequired[Dict[str, str]]): Custom headers to be added to the email.
attachments (NotRequired[List[Union[Attachment, RemoteAttachment]]]): List of attachments to be added to the email.
tags (NotRequired[List[Tag]]): List of tags to be added to the email.
scheduled_at (NotRequired[str]): Schedule email to be sent later.
The date should be in ISO 8601 format (e.g: 2024-08-05T11:52:01.858Z).
template (NotRequired[EmailTemplate]): Template configuration for sending emails using predefined templates.
"""

Expand Down
6 changes: 3 additions & 3 deletions tests/batch_emails_async_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ async def test_batch_email_send_async(self) -> None:
}
)

params: List[resend.Emails.SendParams] = [
params: List[resend.Batch.SendParams] = [
{
"from": "from@resend.dev",
"to": ["to@resend.dev"],
Expand Down Expand Up @@ -52,7 +52,7 @@ async def test_batch_email_send_async_with_options(self) -> None:
}
)

params: List[resend.Emails.SendParams] = [
params: List[resend.Batch.SendParams] = [
{
"from": "from@resend.dev",
"to": ["to@resend.dev"],
Expand Down Expand Up @@ -82,7 +82,7 @@ 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] = [
params: List[resend.Batch.SendParams] = [
{
"from": "from@resend.dev",
"to": ["to@resend.dev"],
Expand Down
46 changes: 40 additions & 6 deletions tests/batch_emails_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from typing import List
from typing import Any, List
from unittest.mock import patch

import resend
from resend import request
from resend.exceptions import NoContentError
from tests.conftest import ResendBaseTest

Expand All @@ -18,7 +20,7 @@ def test_batch_email_send(self) -> None:
}
)

params: List[resend.Emails.SendParams] = [
params: List[resend.Batch.SendParams] = [
{
"from": "from@resend.dev",
"to": ["to@resend.dev"],
Expand Down Expand Up @@ -48,7 +50,7 @@ def test_batch_email_send_with_options(self) -> None:
}
)

params: List[resend.Emails.SendParams] = [
params: List[resend.Batch.SendParams] = [
{
"from": "from@resend.dev",
"to": ["to@resend.dev"],
Expand All @@ -74,7 +76,7 @@ def test_batch_email_send_with_options(self) -> None:

def test_should_send_batch_email_raise_exception_when_no_content(self) -> None:
self.set_mock_json(None)
params: List[resend.Emails.SendParams] = [
params: List[resend.Batch.SendParams] = [
{
"from": "from@resend.dev",
"to": ["to@resend.dev"],
Expand All @@ -101,7 +103,7 @@ def test_batch_email_send_with_strict_validation_mode(self) -> None:
}
)

params: List[resend.Emails.SendParams] = [
params: List[resend.Batch.SendParams] = [
{
"from": "from@resend.dev",
"to": ["to@resend.dev"],
Expand Down Expand Up @@ -141,7 +143,7 @@ def test_batch_email_send_with_permissive_validation_mode(self) -> None:
}
)

params: List[resend.Emails.SendParams] = [
params: List[resend.Batch.SendParams] = [
{
"from": "from@resend.dev",
"to": ["to@resend.dev"],
Expand All @@ -167,3 +169,35 @@ def test_batch_email_send_with_permissive_validation_mode(self) -> None:
assert len(emails["errors"]) == 1
assert emails["errors"][0]["index"] == 1
assert emails["errors"][0]["message"] == "The `to` field is missing."

def test_batch_email_send_with_tags(self) -> None:
self.set_mock_json(
{
"data": [{"id": "ae2014de-c168-4c61-8267-70d2662a1ce1"}]
}
)

params: List[resend.Batch.SendParams] = [
{
"from": "from@resend.dev",
"to": ["to@resend.dev"],
"subject": "hey",
"html": "<strong>hello, world!</strong>",
"tags": [{"name": "category", "value": "notification"}],
}
]

captured_params: List[Any] = []
original_init = request.Request.__init__

def capturing_init(instance: Any, *args: Any, **kwargs: Any) -> None:
captured_params.append(kwargs.get("params"))
original_init(instance, *args, **kwargs)

with patch.object(request.Request, "__init__", capturing_init):
emails: resend.Batch.SendResponse = resend.Batch.send(params)

assert emails["data"][0]["id"] == "ae2014de-c168-4c61-8267-70d2662a1ce1"

sent_params = captured_params[0]
assert sent_params[0]["tags"] == [{"name": "category", "value": "notification"}]
Loading