diff --git a/examples/receiving_email.py b/examples/receiving_email.py index e50656f..2edd1de 100644 --- a/examples/receiving_email.py +++ b/examples/receiving_email.py @@ -38,12 +38,25 @@ print(f"BCC: {received_email.get('bcc', [])}") print(f"Reply-To: {received_email.get('reply_to', [])}") -print("\n--- Headers ---") +print("\n--- Email MIME Headers ---") +# received_email["headers"] contains the MIME headers of the inbound email +# (e.g. X-Mailer, DKIM-Signature). These come from the API response body +# and are part of the email itself, not the HTTP response. if received_email.get("headers"): for header_name, header_value in received_email["headers"].items(): print(f"{header_name}: {header_value}") else: - print("No custom headers") + print("No email headers") + +print("\n--- HTTP Response Headers ---") +# received_email["http_headers"] contains HTTP-level metadata from the Resend API +# (e.g. x-request-id, x-ratelimit-remaining). Injected by the SDK, never part +# of the email content. +if received_email.get("http_headers"): + print(f"Rate limit: {received_email['http_headers'].get('ratelimit-limit')}") + print( + f"Rate limit remaining: {received_email['http_headers'].get('ratelimit-remaining')}" + ) print("\n--- Attachments ---") if received_email["attachments"]: diff --git a/examples/with_headers.py b/examples/with_headers.py index ad36ed7..7d53b81 100644 --- a/examples/with_headers.py +++ b/examples/with_headers.py @@ -1,44 +1,80 @@ """ -Example demonstrating how to access response headers. +Example demonstrating the three different types of headers in the Resend Python SDK: -Response headers include useful information like rate limits, request IDs, etc. +1. Email headers (SendParams["headers"]): Custom MIME headers added to the outgoing + email itself, visible to the recipient's mail client (e.g. X-Entity-Ref-ID). + +2. HTTP response headers (response["http_headers"]): HTTP-level metadata returned + by the Resend API, such as rate limit info and request IDs. These are injected + by the SDK and are never part of the email content. + +3. Inbound email MIME headers (email["headers"]): MIME headers present on a received + email, returned as part of the API response body (e.g. X-Mailer, DKIM-Signature). """ import os import resend -if not os.environ["RESEND_API_KEY"]: - raise EnvironmentError("RESEND_API_KEY is missing") +resend.api_key = os.environ["RESEND_API_KEY"] + +# --- Example 1: Custom email headers (part of the outgoing email itself) --- params: resend.Emails.SendParams = { "from": "onboarding@resend.dev", "to": ["delivered@resend.dev"], "subject": "Hello from Resend", "html": "Hello, world!", + "headers": { + "X-Entity-Ref-ID": "123456789", + }, } resp: resend.Emails.SendResponse = resend.Emails.send(params) print(f"Email sent! ID: {resp['id']}") -if "headers" in resp: - print(f"Request ID: {resp['headers'].get('x-request-id')}") - print(f"Rate limit: {resp['headers'].get('x-ratelimit-limit')}") - print(f"Rate limit remaining: {resp['headers'].get('x-ratelimit-remaining')}") - print(f"Rate limit reset: {resp['headers'].get('x-ratelimit-reset')}") +# --- Example 2: HTTP response headers (SDK metadata, not part of the email) --- + +if "http_headers" in resp: + print(f"Rate limit: {resp['http_headers'].get('ratelimit-limit')}") + print(f"Rate limit remaining: {resp['http_headers'].get('ratelimit-remaining')}") + print(f"Rate limit reset: {resp['http_headers'].get('ratelimit-reset')}") + +# --- Example 3: Inbound email MIME headers (from a received email response body) --- + +# Replace with a real received email ID +received_email_id = os.environ.get("RECEIVED_EMAIL_ID", "") + +if received_email_id: + received: resend.ReceivedEmail = resend.Emails.Receiving.get( + email_id=received_email_id + ) + + # email["headers"] — MIME headers of the inbound email, part of the API response body. + # Completely separate from http_headers injected by the SDK. + if received.get("headers"): + print("Inbound email MIME headers:") + for name, value in received["headers"].items(): + print(f" {name}: {value}") + + # http_headers are also available on received email responses + if received.get("http_headers"): + print( + f"Rate limit remaining: {received['http_headers'].get('ratelimit-remaining')}" + ) +else: + print("Set RECEIVED_EMAIL_ID env var to run the inbound email headers example.") -print("\n") -print("Example 3: Rate limit tracking") +# --- Example 4: Rate limit tracking via HTTP response headers --- def send_with_rate_limit_check(params: resend.Emails.SendParams) -> str: """Example function showing how to track rate limits.""" response = resend.Emails.send(params) - # Access headers via dict key - headers = response.get("headers", {}) - remaining = headers.get("x-ratelimit-remaining") - limit = headers.get("x-ratelimit-limit") + http_headers = response.get("http_headers", {}) + remaining = http_headers.get("ratelimit-remaining") + limit = http_headers.get("ratelimit-limit") if remaining and limit: print(f"Rate limit usage: {int(limit) - int(remaining)}/{limit}") diff --git a/resend/_base_response.py b/resend/_base_response.py index 47ac531..1f0d23a 100644 --- a/resend/_base_response.py +++ b/resend/_base_response.py @@ -9,8 +9,8 @@ class BaseResponse(TypedDict): """Base response type that all API responses inherit from. Attributes: - headers: HTTP response headers including rate limit info, request IDs, etc. - Optional field that may not be present in all responses. + http_headers: HTTP response headers including rate limit info, request IDs, etc. + Optional field that may not be present in all responses. """ - headers: NotRequired[Dict[str, str]] + http_headers: NotRequired[Dict[str, str]] diff --git a/resend/emails/_emails.py b/resend/emails/_emails.py index 9f7416a..4921e09 100644 --- a/resend/emails/_emails.py +++ b/resend/emails/_emails.py @@ -201,7 +201,7 @@ class SendResponse(BaseResponse): Attributes: id (str): The ID of the sent email - headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse) + http_headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse) """ id: str @@ -240,7 +240,7 @@ class ListResponse(BaseResponse): object (str): The object type: "list" data (List[Email]): The list of email objects. has_more (bool): Whether there are more emails available for pagination. - headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse) + http_headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse) """ object: str diff --git a/resend/emails/_received_email.py b/resend/emails/_received_email.py index 5176b18..4234257 100644 --- a/resend/emails/_received_email.py +++ b/resend/emails/_received_email.py @@ -2,6 +2,8 @@ from typing_extensions import NotRequired, TypedDict +from resend._base_response import BaseResponse + class EmailAttachment(TypedDict): """ @@ -109,7 +111,7 @@ class EmailAttachmentDetails(TypedDict): ) -class _ReceivedEmailDefaultAttrs(_ReceivedEmailFromParam): +class _ReceivedEmailDefaultAttrs(_ReceivedEmailFromParam, BaseResponse): object: str """ The object type. diff --git a/resend/request.py b/resend/request.py index 5d8ddf3..26e2697 100644 --- a/resend/request.py +++ b/resend/request.py @@ -114,7 +114,7 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: parsed_data = cast(Union[Dict[str, Any], List[Any]], json.loads(content)) # Inject headers into dict responses if isinstance(parsed_data, dict): - parsed_data["headers"] = dict(self._response_headers) + parsed_data["http_headers"] = dict(self._response_headers) # For list responses, return as-is (lists can't have headers key) return parsed_data except json.JSONDecodeError: diff --git a/resend/version.py b/resend/version.py index 68580de..327a273 100644 --- a/resend/version.py +++ b/resend/version.py @@ -1,4 +1,4 @@ -__version__ = "2.24.0" +__version__ = "2.25.0" def get_version() -> str: diff --git a/tests/emails_test.py b/tests/emails_test.py index 62faf01..6da9b90 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock import resend from resend import EmailsReceiving @@ -485,3 +485,65 @@ def test_email_send_with_template_and_variables(self) -> None: } email: resend.Emails.SendResponse = resend.Emails.send(params) assert email["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" + + def test_email_send_with_custom_headers(self) -> None: + self.set_mock_json( + { + "id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794", + } + ) + params: resend.Emails.SendParams = { + "to": "to@email.com", + "from": "from@email.com", + "subject": "subject", + "html": "html", + "headers": { + "X-Entity-Ref-ID": "123456", + }, + } + email: resend.Emails.SendResponse = resend.Emails.send(params) + assert email["id"] == "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" + + +import unittest as _unittest + + +class TestEmailHeadersRegression(_unittest.TestCase): + """ + Tests that mock at the HTTP client level to exercise request.py's injection + code. ResendBaseTest mocks make_request directly, which bypasses that code + and would not have caught the v2.23.0 regression. + """ + + def setUp(self) -> None: + resend.api_key = "re_123" + + def test_receiving_get_email_headers_not_overwritten_by_http_headers(self) -> None: + mock_client = Mock() + mock_client.request.return_value = ( + b'{"object":"inbound","id":"67d9bcdb-5a02-42d7-8da9-0d6feea18cff",' + b'"to":["received@example.com"],"from":"sender@example.com",' + b'"created_at":"2023-04-07T23:13:52.669661+00:00","subject":"Test",' + b'"html":null,"text":"hello","bcc":null,"cc":null,"reply_to":null,' + b'"message_id":"","headers":{"X-Custom":"email-value"},' + b'"attachments":[]}', + 200, + { + "content-type": "application/json", + "x-request-id": "req_abc123", + }, + ) + + original_client = resend.default_http_client + resend.default_http_client = mock_client + + try: + email: resend.ReceivedEmail = resend.Emails.Receiving.get( + email_id="67d9bcdb-5a02-42d7-8da9-0d6feea18cff", + ) + # Email MIME headers must survive the HTTP headers injection + assert email["headers"] == {"X-Custom": "email-value"} + # HTTP response headers are available separately + assert email["http_headers"]["x-request-id"] == "req_abc123" + finally: + resend.default_http_client = original_client diff --git a/tests/response_headers_integration_test.py b/tests/response_headers_integration_test.py index 2816025..668a3cb 100644 --- a/tests/response_headers_integration_test.py +++ b/tests/response_headers_integration_test.py @@ -48,11 +48,11 @@ def test_email_send_response_includes_headers(self) -> None: assert response.get("from") == "test@example.com" # Verify new feature - headers are accessible via dict key - assert "headers" in response - assert response["headers"]["x-request-id"] == "req_abc123" - assert response["headers"]["x-ratelimit-limit"] == "100" - assert response["headers"]["x-ratelimit-remaining"] == "95" - assert response["headers"]["x-ratelimit-reset"] == "1699564800" + assert "http_headers" in response + assert response["http_headers"]["x-request-id"] == "req_abc123" + assert response["http_headers"]["x-ratelimit-limit"] == "100" + assert response["http_headers"]["x-ratelimit-remaining"] == "95" + assert response["http_headers"]["x-ratelimit-reset"] == "1699564800" finally: # Restore original HTTP client @@ -82,8 +82,8 @@ def test_list_response_headers(self) -> None: assert isinstance(response, dict) assert "data" in response # Headers are injected into the dict - assert "headers" in response - assert response["headers"]["x-request-id"] == "req_xyz" + assert "http_headers" in response + assert response["http_headers"]["x-request-id"] == "req_xyz" finally: resend.default_http_client = original_client