Skip to content

Commit dbe8bc2

Browse files
committed
feat: impl batch validation mode
1 parent b8e2d65 commit dbe8bc2

5 files changed

Lines changed: 159 additions & 5 deletions

File tree

‎examples/batch_email_send.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,45 @@
4949
print("Failed to send batch emails")
5050
print(f"Error: {err}")
5151
exit(1)
52+
53+
try:
54+
# Send batch emails with permissive validation mode
55+
print("sending with permissive validation mode")
56+
57+
# Example with some invalid emails to demonstrate error handling
58+
mixed_params: List[resend.Emails.SendParams] = [
59+
{
60+
"from": "onboarding@resend.dev",
61+
"to": ["delivered@resend.dev"],
62+
"subject": "Valid email",
63+
"html": "<strong>This should work!</strong>",
64+
},
65+
{
66+
"from": "onboarding@resend.dev",
67+
"to": [], # Invalid - empty to field
68+
"subject": "Invalid email",
69+
"html": "<strong>This should fail!</strong>",
70+
},
71+
]
72+
73+
options_permissive: resend.Batch.SendOptions = {
74+
"batch_validation": "permissive",
75+
}
76+
77+
result: resend.Batch.SendResponse = resend.Batch.send(
78+
mixed_params, options=options_permissive
79+
)
80+
81+
print(f"Successfully sent {len(result['data'])} emails:")
82+
for email in result["data"]:
83+
print(f" Email id: {email['id']}")
84+
85+
if "errors" in result and result["errors"]:
86+
print(f"Validation errors for {len(result['errors'])} emails:")
87+
for error in result["errors"]:
88+
print(f" Index {error['index']}: {error['message']}")
89+
90+
except resend.exceptions.ResendError as err:
91+
print("Failed to send batch emails")
92+
print(f"Error: {err}")
93+
exit(1)

‎resend/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .domains._domain import Domain
1212
from .domains._domains import Domains
1313
from .emails._attachment import Attachment, RemoteAttachment
14-
from .emails._batch import Batch
14+
from .emails._batch import Batch, BatchValidationError
1515
from .emails._email import Email
1616
from .emails._emails import Emails
1717
from .emails._tag import Tag
@@ -51,6 +51,7 @@
5151
"RemoteAttachment",
5252
"Tag",
5353
"Broadcast",
54+
"BatchValidationError",
5455
# Default HTTP Client
5556
"RequestsClient",
5657
]

‎resend/emails/_batch.py‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any, Dict, List, Optional, cast
22

3-
from typing_extensions import NotRequired, TypedDict
3+
from typing_extensions import Literal, NotRequired, TypedDict
44

55
from resend import request
66

@@ -14,6 +14,25 @@ class SendEmailResponse(TypedDict):
1414
"""
1515

1616

17+
class BatchValidationError(TypedDict):
18+
"""
19+
BatchValidationError represents a validation error for a specific email in the batch.
20+
21+
Attributes:
22+
index (int): The index of the email in the batch that caused the error
23+
message (str): The validation error message
24+
"""
25+
26+
index: int
27+
"""
28+
The index of the email in the batch that caused the error.
29+
"""
30+
message: str
31+
"""
32+
The validation error message.
33+
"""
34+
35+
1736
class Batch:
1837

1938
class SendOptions(TypedDict):
@@ -24,6 +43,8 @@ class SendOptions(TypedDict):
2443
idempotency_key (NotRequired[str]): Unique key that ensures the same operation is not processed multiple times.
2544
Allows for safe retries without duplicating operations.
2645
If provided, will be sent as the `Idempotency-Key` header.
46+
batch_validation (NotRequired[Literal["strict", "permissive"]]): Batch validation mode.
47+
Defaults to "strict" when not provided.
2748
"""
2849

2950
idempotency_key: NotRequired[str]
@@ -33,11 +54,21 @@ class SendOptions(TypedDict):
3354
If provided, will be sent as the `Idempotency-Key` header.
3455
"""
3556

57+
batch_validation: NotRequired[Literal["strict", "permissive"]]
58+
"""
59+
Batch validation mode.
60+
Defaults to "strict" when not provided.
61+
"""
62+
3663
class SendResponse(TypedDict):
3764
data: List[SendEmailResponse]
3865
"""
3966
A list of email objects
4067
"""
68+
errors: NotRequired[List[BatchValidationError]]
69+
"""
70+
A list of validation errors (only present in permissive mode)
71+
"""
4172

4273
@classmethod
4374
def send(
@@ -49,10 +80,10 @@ def send(
4980
5081
Args:
5182
params (List[Emails.SendParams]): The list of emails to send
52-
options (Optional[SendOptions]): Batch options, ie: idempotency_key
83+
options (Optional[SendOptions]): Batch options, including batch_validation mode
5384
5485
Returns:
55-
SendResponse: A list of email objects
86+
SendResponse: A list of email objects, and optionally validation errors in permissive mode
5687
"""
5788
path = "/emails/batch"
5889

‎resend/request.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ def __get_headers(self) -> HeadersType:
5656
if self.verb == "post" and self.options and "idempotency_key" in self.options:
5757
headers["Idempotency-Key"] = str(self.options["idempotency_key"])
5858

59+
if self.verb == "post" and self.options and "batch_validation" in self.options:
60+
headers["x-batch-validation"] = str(self.options["batch_validation"])
61+
5962
return headers
6063

6164
def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]:

‎tests/batch_emails_test.py‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def test_batch_email_send_with_options(self) -> None:
6363
},
6464
]
6565

66-
options: resend.Emails.SendOptions = {
66+
options: resend.Batch.SendOptions = {
6767
"idempotency_key": "af477dc78aa9fa91fff3b8c0d4a2e1a5",
6868
}
6969

@@ -90,3 +90,80 @@ def test_should_send_batch_email_raise_exception_when_no_content(self) -> None:
9090
]
9191
with self.assertRaises(NoContentError):
9292
_ = resend.Batch.send(params)
93+
94+
def test_batch_email_send_with_strict_validation_mode(self) -> None:
95+
self.set_mock_json(
96+
{
97+
"data": [
98+
{"id": "ae2014de-c168-4c61-8267-70d2662a1ce1"},
99+
{"id": "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb"},
100+
]
101+
}
102+
)
103+
104+
params: List[resend.Emails.SendParams] = [
105+
{
106+
"from": "from@resend.dev",
107+
"to": ["to@resend.dev"],
108+
"subject": "hey",
109+
"html": "<strong>hello, world!</strong>",
110+
},
111+
{
112+
"from": "from@resend.dev",
113+
"to": ["to@resend.dev"],
114+
"subject": "hello",
115+
"html": "<strong>hello, world!</strong>",
116+
},
117+
]
118+
119+
options: resend.Batch.SendOptions = {
120+
"batch_validation": "strict",
121+
}
122+
123+
emails: resend.Batch.SendResponse = resend.Batch.send(params, options=options)
124+
assert len(emails["data"]) == 2
125+
assert emails["data"][0]["id"] == "ae2014de-c168-4c61-8267-70d2662a1ce1"
126+
assert emails["data"][1]["id"] == "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb"
127+
assert "errors" not in emails
128+
129+
def test_batch_email_send_with_permissive_validation_mode(self) -> None:
130+
self.set_mock_json(
131+
{
132+
"data": [
133+
{"id": "ae2014de-c168-4c61-8267-70d2662a1ce1"},
134+
],
135+
"errors": [
136+
{
137+
"index": 1,
138+
"message": "The `to` field is missing.",
139+
}
140+
],
141+
}
142+
)
143+
144+
params: List[resend.Emails.SendParams] = [
145+
{
146+
"from": "from@resend.dev",
147+
"to": ["to@resend.dev"],
148+
"subject": "hey",
149+
"html": "<strong>hello, world!</strong>",
150+
},
151+
{
152+
"from": "from@resend.dev",
153+
"to": [],
154+
"subject": "hello",
155+
"html": "<strong>hello, world!</strong>",
156+
},
157+
]
158+
159+
options: resend.Batch.SendOptions = {
160+
"batch_validation": "permissive",
161+
}
162+
163+
emails: resend.Batch.SendResponse = resend.Batch.send(params, options=options)
164+
assert len(emails["data"]) == 1
165+
assert emails["data"][0]["id"] == "ae2014de-c168-4c61-8267-70d2662a1ce1"
166+
assert "errors" in emails
167+
assert len(emails["errors"]) == 1
168+
assert emails["errors"][0]["index"] == 1
169+
assert emails["errors"][0]["message"] == "The `to` field is missing."

0 commit comments

Comments
 (0)