Skip to content
Merged
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
17 changes: 15 additions & 2 deletions examples/receiving_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
66 changes: 51 additions & 15 deletions examples/with_headers.py
Original file line number Diff line number Diff line change
@@ -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": "<strong>Hello, world!</strong>",
"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(
Copy link
Contributor

Choose a reason for hiding this comment

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

P1: Custom agent: API Key Permission Check SDK Methods

Emails.Receiving.get() is a new Resend SDK operation that needs broader API-key permissions than Emails.send(), but this example still reuses RESEND_API_KEY without calling out the full-access requirement. Under the API Key Permission Check SDK Methods rule, document that permission here so send-only production keys do not fail on this new path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/with_headers.py, line 49:

<comment>`Emails.Receiving.get()` is a new Resend SDK operation that needs broader API-key permissions than `Emails.send()`, but this example still reuses `RESEND_API_KEY` without calling out the full-access requirement. Under the API Key Permission Check SDK Methods rule, document that permission here so send-only production keys do not fail on this new path.</comment>

<file context>
@@ -1,44 +1,78 @@
+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
+    )
</file context>
Suggested change
received: resend.ReceivedEmail = resend.Emails.Receiving.get(
# `Emails.Receiving.get()` uses Resend's receiving/read API and requires a full-access API key, not a send-only key.
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}")
Expand Down
6 changes: 3 additions & 3 deletions resend/_base_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
4 changes: 2 additions & 2 deletions resend/emails/_emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion resend/emails/_received_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from typing_extensions import NotRequired, TypedDict

from resend._base_response import BaseResponse


class EmailAttachment(TypedDict):
"""
Expand Down Expand Up @@ -109,7 +111,7 @@ class EmailAttachmentDetails(TypedDict):
)


class _ReceivedEmailDefaultAttrs(_ReceivedEmailFromParam):
class _ReceivedEmailDefaultAttrs(_ReceivedEmailFromParam, BaseResponse):
object: str
"""
The object type.
Expand Down
2 changes: 1 addition & 1 deletion resend/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion resend/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "2.24.0"
__version__ = "2.25.0"


def get_version() -> str:
Expand Down
64 changes: 63 additions & 1 deletion tests/emails_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from unittest.mock import MagicMock
from unittest.mock import MagicMock, Mock

import resend
from resend import EmailsReceiving
Expand Down Expand Up @@ -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":"<msg123>","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
14 changes: 7 additions & 7 deletions tests/response_headers_integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading