Skip to content

Commit a835d5e

Browse files
committed
chore: merge main into async-support
2 parents b404731 + 7d98e8e commit a835d5e

10 files changed

Lines changed: 172 additions & 32 deletions

File tree

examples/receiving_email.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,25 @@
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(
58+
f"Rate limit remaining: {received_email['http_headers'].get('ratelimit-remaining')}"
59+
)
4760

4861
print("\n--- Attachments ---")
4962
if received_email["attachments"]:

examples/with_headers.py

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,80 @@
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(
63+
f"Rate limit remaining: {received['http_headers'].get('ratelimit-remaining')}"
64+
)
65+
else:
66+
print("Set RECEIVED_EMAIL_ID env var to run the inbound email headers example.")
2967

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

3370

3471
def send_with_rate_limit_check(params: resend.Emails.SendParams) -> str:
3572
"""Example function showing how to track rate limits."""
3673
response = resend.Emails.send(params)
3774

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")
75+
http_headers = response.get("http_headers", {})
76+
remaining = http_headers.get("ratelimit-remaining")
77+
limit = http_headers.get("ratelimit-limit")
4278

4379
if remaining and limit:
4480
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/api_keys/_api_key.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Optional
2+
13
from typing_extensions import TypedDict
24

35

@@ -14,3 +16,7 @@ class ApiKey(TypedDict):
1416
"""
1517
The API key creation date
1618
"""
19+
last_used_at: Optional[str]
20+
"""
21+
The date and time the API key was last used, or None if never used
22+
"""

resend/emails/_emails.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ class SendResponse(BaseResponse):
207207
208208
Attributes:
209209
id (str): The ID of the sent email
210-
headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse)
210+
http_headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse)
211211
"""
212212

213213
id: str
@@ -246,7 +246,7 @@ class ListResponse(BaseResponse):
246246
object (str): The object type: "list"
247247
data (List[Email]): The list of email objects.
248248
has_more (bool): Whether there are more emails available for pagination.
249-
headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse)
249+
http_headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse)
250250
"""
251251

252252
object: str

resend/emails/_received_email.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from typing_extensions import NotRequired, TypedDict
44

5+
from resend._base_response import BaseResponse
6+
57

68
class EmailAttachment(TypedDict):
79
"""
@@ -109,7 +111,7 @@ class EmailAttachmentDetails(TypedDict):
109111
)
110112

111113

112-
class _ReceivedEmailDefaultAttrs(_ReceivedEmailFromParam):
114+
class _ReceivedEmailDefaultAttrs(_ReceivedEmailFromParam, BaseResponse):
113115
object: str
114116
"""
115117
The object type.

resend/request.py

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

tests/api_keys_test.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def test_api_keys_list(self) -> None:
3838
"id": "91f3200a-df72-4654-b0cd-f202395f5354",
3939
"name": "Production",
4040
"created_at": "2023-04-08T00:11:13.110779+00:00",
41+
"last_used_at": "2023-04-08T12:00:00.000000+00:00",
4142
}
4243
],
4344
}
@@ -50,6 +51,26 @@ def test_api_keys_list(self) -> None:
5051
assert key["id"] == "91f3200a-df72-4654-b0cd-f202395f5354"
5152
assert key["name"] == "Production"
5253
assert key["created_at"] == "2023-04-08T00:11:13.110779+00:00"
54+
assert key["last_used_at"] == "2023-04-08T12:00:00.000000+00:00"
55+
56+
def test_api_keys_list_last_used_at_none(self) -> None:
57+
self.set_mock_json(
58+
{
59+
"object": "list",
60+
"has_more": False,
61+
"data": [
62+
{
63+
"id": "91f3200a-df72-4654-b0cd-f202395f5354",
64+
"name": "Production",
65+
"created_at": "2023-04-08T00:11:13.110779+00:00",
66+
"last_used_at": None,
67+
}
68+
],
69+
}
70+
)
71+
72+
keys: resend.ApiKeys.ListResponse = resend.ApiKeys.list()
73+
assert keys["data"][0]["last_used_at"] is None
5374

5475
def test_should_list_api_key_raise_exception_when_no_content(self) -> None:
5576
self.set_mock_json(None)

tests/emails_test.py

Lines changed: 63 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,65 @@ 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+
import unittest as _unittest
509+
510+
511+
class TestEmailHeadersRegression(_unittest.TestCase):
512+
"""
513+
Tests that mock at the HTTP client level to exercise request.py's injection
514+
code. ResendBaseTest mocks make_request directly, which bypasses that code
515+
and would not have caught the v2.23.0 regression.
516+
"""
517+
518+
def setUp(self) -> None:
519+
resend.api_key = "re_123"
520+
521+
def test_receiving_get_email_headers_not_overwritten_by_http_headers(self) -> None:
522+
mock_client = Mock()
523+
mock_client.request.return_value = (
524+
b'{"object":"inbound","id":"67d9bcdb-5a02-42d7-8da9-0d6feea18cff",'
525+
b'"to":["received@example.com"],"from":"sender@example.com",'
526+
b'"created_at":"2023-04-07T23:13:52.669661+00:00","subject":"Test",'
527+
b'"html":null,"text":"hello","bcc":null,"cc":null,"reply_to":null,'
528+
b'"message_id":"<msg123>","headers":{"X-Custom":"email-value"},'
529+
b'"attachments":[]}',
530+
200,
531+
{
532+
"content-type": "application/json",
533+
"x-request-id": "req_abc123",
534+
},
535+
)
536+
537+
original_client = resend.default_http_client
538+
resend.default_http_client = mock_client
539+
540+
try:
541+
email: resend.ReceivedEmail = resend.Emails.Receiving.get(
542+
email_id="67d9bcdb-5a02-42d7-8da9-0d6feea18cff",
543+
)
544+
# Email MIME headers must survive the HTTP headers injection
545+
assert email["headers"] == {"X-Custom": "email-value"}
546+
# HTTP response headers are available separately
547+
assert email["http_headers"]["x-request-id"] == "req_abc123"
548+
finally:
549+
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)