Skip to content

Commit 5fc326c

Browse files
committed
fix: rename http response headers key to http_headers to avoid collision with email headers
Fixes a regression introduced in v2.23.0 where injecting HTTP response headers under the 'headers' key would overwrite the email MIME headers field present in ReceivedEmail responses. - Rename injected key from 'headers' to 'http_headers' in request.py - Update BaseResponse TypedDict field accordingly - Add regression test that mocks at HTTP client level (not make_request) to ensure the injection code is exercised - Add test for SendParams['headers'] custom email headers - Update examples to document all three header types
1 parent 90afd93 commit 5fc326c

8 files changed

Lines changed: 140 additions & 32 deletions

File tree

examples/receiving_email.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,23 @@
3838
print(f"BCC: {received_email.get('bcc', [])}")
3939
print(f"Reply-To: {received_email.get('reply_to', [])}")
4040

41-
print("\n--- Headers ---")
41+
print("\n--- Email MIME Headers ---")
42+
# received_email["headers"] contains the MIME headers of the inbound email
43+
# (e.g. X-Mailer, DKIM-Signature). These come from the API response body
44+
# and are part of the email itself, not the HTTP response.
4245
if received_email.get("headers"):
4346
for header_name, header_value in received_email["headers"].items():
4447
print(f"{header_name}: {header_value}")
4548
else:
46-
print("No custom headers")
49+
print("No email headers")
50+
51+
print("\n--- HTTP Response Headers ---")
52+
# received_email["http_headers"] contains HTTP-level metadata from the Resend API
53+
# (e.g. x-request-id, x-ratelimit-remaining). Injected by the SDK, never part
54+
# of the email content.
55+
if received_email.get("http_headers"):
56+
print(f"Rate limit: {received_email['http_headers'].get('ratelimit-limit')}")
57+
print(f"Rate limit remaining: {received_email['http_headers'].get('ratelimit-remaining')}")
4758

4859
print("\n--- Attachments ---")
4960
if received_email["attachments"]:

examples/with_headers.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,78 @@
11
"""
2-
Example demonstrating how to access response headers.
2+
Example demonstrating the three different types of headers in the Resend Python SDK:
33
4-
Response headers include useful information like rate limits, request IDs, etc.
4+
1. Email headers (SendParams["headers"]): Custom MIME headers added to the outgoing
5+
email itself, visible to the recipient's mail client (e.g. X-Entity-Ref-ID).
6+
7+
2. HTTP response headers (response["http_headers"]): HTTP-level metadata returned
8+
by the Resend API, such as rate limit info and request IDs. These are injected
9+
by the SDK and are never part of the email content.
10+
11+
3. Inbound email MIME headers (email["headers"]): MIME headers present on a received
12+
email, returned as part of the API response body (e.g. X-Mailer, DKIM-Signature).
513
"""
614

715
import os
816

917
import resend
1018

11-
if not os.environ["RESEND_API_KEY"]:
12-
raise EnvironmentError("RESEND_API_KEY is missing")
19+
resend.api_key = os.environ["RESEND_API_KEY"]
20+
21+
# --- Example 1: Custom email headers (part of the outgoing email itself) ---
1322

1423
params: resend.Emails.SendParams = {
1524
"from": "onboarding@resend.dev",
1625
"to": ["delivered@resend.dev"],
1726
"subject": "Hello from Resend",
1827
"html": "<strong>Hello, world!</strong>",
28+
"headers": {
29+
"X-Entity-Ref-ID": "123456789",
30+
},
1931
}
2032

2133
resp: resend.Emails.SendResponse = resend.Emails.send(params)
2234
print(f"Email sent! ID: {resp['id']}")
2335

24-
if "headers" in resp:
25-
print(f"Request ID: {resp['headers'].get('x-request-id')}")
26-
print(f"Rate limit: {resp['headers'].get('x-ratelimit-limit')}")
27-
print(f"Rate limit remaining: {resp['headers'].get('x-ratelimit-remaining')}")
28-
print(f"Rate limit reset: {resp['headers'].get('x-ratelimit-reset')}")
36+
# --- Example 2: HTTP response headers (SDK metadata, not part of the email) ---
37+
38+
if "http_headers" in resp:
39+
print(f"Rate limit: {resp['http_headers'].get('ratelimit-limit')}")
40+
print(f"Rate limit remaining: {resp['http_headers'].get('ratelimit-remaining')}")
41+
print(f"Rate limit reset: {resp['http_headers'].get('ratelimit-reset')}")
42+
43+
# --- Example 3: Inbound email MIME headers (from a received email response body) ---
44+
45+
# Replace with a real received email ID
46+
received_email_id = os.environ.get("RECEIVED_EMAIL_ID", "")
47+
48+
if received_email_id:
49+
received: resend.ReceivedEmail = resend.Emails.Receiving.get(
50+
email_id=received_email_id
51+
)
52+
53+
# email["headers"] — MIME headers of the inbound email, part of the API response body.
54+
# Completely separate from http_headers injected by the SDK.
55+
if received.get("headers"):
56+
print("Inbound email MIME headers:")
57+
for name, value in received["headers"].items():
58+
print(f" {name}: {value}")
59+
60+
# http_headers are also available on received email responses
61+
if received.get("http_headers"):
62+
print(f"Rate limit remaining: {received['http_headers'].get('ratelimit-remaining')}")
63+
else:
64+
print("Set RECEIVED_EMAIL_ID env var to run the inbound email headers example.")
2965

30-
print("\n")
31-
print("Example 3: Rate limit tracking")
66+
# --- Example 4: Rate limit tracking via HTTP response headers ---
3267

3368

3469
def send_with_rate_limit_check(params: resend.Emails.SendParams) -> str:
3570
"""Example function showing how to track rate limits."""
3671
response = resend.Emails.send(params)
3772

38-
# Access headers via dict key
39-
headers = response.get("headers", {})
40-
remaining = headers.get("x-ratelimit-remaining")
41-
limit = headers.get("x-ratelimit-limit")
73+
http_headers = response.get("http_headers", {})
74+
remaining = http_headers.get("ratelimit-remaining")
75+
limit = http_headers.get("ratelimit-limit")
4276

4377
if remaining and limit:
4478
print(f"Rate limit usage: {int(limit) - int(remaining)}/{limit}")

resend/_base_response.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ class BaseResponse(TypedDict):
99
"""Base response type that all API responses inherit from.
1010
1111
Attributes:
12-
headers: HTTP response headers including rate limit info, request IDs, etc.
13-
Optional field that may not be present in all responses.
12+
http_headers: HTTP response headers including rate limit info, request IDs, etc.
13+
Optional field that may not be present in all responses.
1414
"""
1515

16-
headers: NotRequired[Dict[str, str]]
16+
http_headers: NotRequired[Dict[str, str]]

resend/emails/_emails.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ class SendResponse(BaseResponse):
201201
202202
Attributes:
203203
id (str): The ID of the sent email
204-
headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse)
204+
http_headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse)
205205
"""
206206

207207
id: str
@@ -240,7 +240,7 @@ class ListResponse(BaseResponse):
240240
object (str): The object type: "list"
241241
data (List[Email]): The list of email objects.
242242
has_more (bool): Whether there are more emails available for pagination.
243-
headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse)
243+
http_headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse)
244244
"""
245245

246246
object: str

resend/request.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]:
114114
parsed_data = cast(Union[Dict[str, Any], List[Any]], json.loads(content))
115115
# Inject headers into dict responses
116116
if isinstance(parsed_data, dict):
117-
parsed_data["headers"] = dict(self._response_headers)
117+
parsed_data["http_headers"] = dict(self._response_headers)
118118
# For list responses, return as-is (lists can't have headers key)
119119
return parsed_data
120120
except json.JSONDecodeError:

resend/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "2.24.0"
1+
__version__ = "2.25.0"
22

33

44
def get_version() -> str:

tests/emails_test.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from unittest.mock import MagicMock
1+
from unittest.mock import MagicMock, Mock
22

33
import resend
44
from resend import EmailsReceiving
@@ -485,3 +485,66 @@ def test_email_send_with_template_and_variables(self) -> None:
485485
}
486486
email: resend.Emails.SendResponse = resend.Emails.send(params)
487487
assert email["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"
488+
489+
def test_email_send_with_custom_headers(self) -> None:
490+
self.set_mock_json(
491+
{
492+
"id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794",
493+
}
494+
)
495+
params: resend.Emails.SendParams = {
496+
"to": "to@email.com",
497+
"from": "from@email.com",
498+
"subject": "subject",
499+
"html": "html",
500+
"headers": {
501+
"X-Entity-Ref-ID": "123456",
502+
},
503+
}
504+
email: resend.Emails.SendResponse = resend.Emails.send(params)
505+
assert email["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"
506+
507+
508+
509+
import unittest as _unittest
510+
511+
512+
class TestEmailHeadersRegression(_unittest.TestCase):
513+
"""
514+
Tests that mock at the HTTP client level to exercise request.py's injection
515+
code. ResendBaseTest mocks make_request directly, which bypasses that code
516+
and would not have caught the v2.23.0 regression.
517+
"""
518+
519+
def setUp(self) -> None:
520+
resend.api_key = "re_123"
521+
522+
def test_receiving_get_email_headers_not_overwritten_by_http_headers(self) -> None:
523+
mock_client = Mock()
524+
mock_client.request.return_value = (
525+
b'{"object":"inbound","id":"67d9bcdb-5a02-42d7-8da9-0d6feea18cff",'
526+
b'"to":["received@example.com"],"from":"sender@example.com",'
527+
b'"created_at":"2023-04-07T23:13:52.669661+00:00","subject":"Test",'
528+
b'"html":null,"text":"hello","bcc":null,"cc":null,"reply_to":null,'
529+
b'"message_id":"<msg123>","headers":{"X-Custom":"email-value"},'
530+
b'"attachments":[]}',
531+
200,
532+
{
533+
"content-type": "application/json",
534+
"x-request-id": "req_abc123",
535+
},
536+
)
537+
538+
original_client = resend.default_http_client
539+
resend.default_http_client = mock_client
540+
541+
try:
542+
email: resend.ReceivedEmail = resend.Emails.Receiving.get(
543+
email_id="67d9bcdb-5a02-42d7-8da9-0d6feea18cff",
544+
)
545+
# Email MIME headers must survive the HTTP headers injection
546+
assert email["headers"] == {"X-Custom": "email-value"}
547+
# HTTP response headers are available separately
548+
assert email["http_headers"]["x-request-id"] == "req_abc123"
549+
finally:
550+
resend.default_http_client = original_client

tests/response_headers_integration_test.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@ def test_email_send_response_includes_headers(self) -> None:
4848
assert response.get("from") == "test@example.com"
4949

5050
# Verify new feature - headers are accessible via dict key
51-
assert "headers" in response
52-
assert response["headers"]["x-request-id"] == "req_abc123"
53-
assert response["headers"]["x-ratelimit-limit"] == "100"
54-
assert response["headers"]["x-ratelimit-remaining"] == "95"
55-
assert response["headers"]["x-ratelimit-reset"] == "1699564800"
51+
assert "http_headers" in response
52+
assert response["http_headers"]["x-request-id"] == "req_abc123"
53+
assert response["http_headers"]["x-ratelimit-limit"] == "100"
54+
assert response["http_headers"]["x-ratelimit-remaining"] == "95"
55+
assert response["http_headers"]["x-ratelimit-reset"] == "1699564800"
5656

5757
finally:
5858
# Restore original HTTP client
@@ -82,8 +82,8 @@ def test_list_response_headers(self) -> None:
8282
assert isinstance(response, dict)
8383
assert "data" in response
8484
# Headers are injected into the dict
85-
assert "headers" in response
86-
assert response["headers"]["x-request-id"] == "req_xyz"
85+
assert "http_headers" in response
86+
assert response["http_headers"]["x-request-id"] == "req_xyz"
8787

8888
finally:
8989
resend.default_http_client = original_client

0 commit comments

Comments
 (0)