From a6f1cc13f45df6e889eb2cff4f579203f909900e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 18:59:19 +0000 Subject: [PATCH 1/4] feat: add message_id typing for emails and webhook events Add message_id to the Email type for GET /emails/:id and list responses. Introduce typed webhook event payloads (WebhookEventPayload) matching the Node.js SDK, including message_id on all outbound email webhook events. Update Webhooks.verify to return the parsed and verified event payload. Co-authored-by: cpenned --- resend/__init__.py | 44 +++ resend/emails/_email.py | 5 + resend/webhooks/_webhook_event.py | 604 ++++++++++++++++++++++++++++++ resend/webhooks/_webhooks.py | 14 +- tests/emails_test.py | 7 + tests/webhooks_test.py | 41 +- 6 files changed, 706 insertions(+), 9 deletions(-) create mode 100644 resend/webhooks/_webhook_event.py diff --git a/resend/__init__.py b/resend/__init__.py index 7b4b4e8..61d72b5 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -57,6 +57,22 @@ from .version import __version__, get_version from .webhooks._webhook import (VerifyWebhookOptions, Webhook, WebhookEvent, WebhookHeaders, WebhookStatus) +from .webhooks._webhook_event import (BaseEmailEventData, ContactCreatedEvent, + ContactDeletedEvent, ContactUpdatedEvent, + ContactEventData, DomainCreatedEvent, + DomainDeletedEvent, DomainEventData, + DomainRecord, DomainUpdatedEvent, + EmailBounce, EmailBouncedEvent, + EmailClickedEvent, EmailClick, + EmailComplainedEvent, EmailDeliveredEvent, + EmailDeliveryDelayedEvent, + EmailFailed, EmailFailedEvent, + EmailOpenedEvent, EmailReceivedEvent, + EmailScheduledEvent, EmailSentEvent, + EmailSuppressed, EmailSuppressedEvent, + ReceivedEmailAttachment, + ReceivedEmailEventData, + WebhookEventPayload) from .webhooks._webhooks import Webhooks # Type for clients that support both sync and async @@ -135,9 +151,37 @@ "Variable", "Webhook", "WebhookEvent", + "WebhookEventPayload", "WebhookHeaders", "WebhookStatus", "VerifyWebhookOptions", + "BaseEmailEventData", + "EmailBounce", + "EmailClick", + "EmailFailed", + "EmailSuppressed", + "ReceivedEmailAttachment", + "ReceivedEmailEventData", + "ContactEventData", + "DomainRecord", + "DomainEventData", + "EmailSentEvent", + "EmailScheduledEvent", + "EmailDeliveredEvent", + "EmailDeliveryDelayedEvent", + "EmailComplainedEvent", + "EmailBouncedEvent", + "EmailOpenedEvent", + "EmailClickedEvent", + "EmailReceivedEvent", + "EmailFailedEvent", + "EmailSuppressedEvent", + "ContactCreatedEvent", + "ContactUpdatedEvent", + "ContactDeletedEvent", + "DomainCreatedEvent", + "DomainUpdatedEvent", + "DomainDeletedEvent", "Topic", "BatchValidationError", "ReceivedEmail", diff --git a/resend/emails/_email.py b/resend/emails/_email.py index 09dd1af..04d517a 100644 --- a/resend/emails/_email.py +++ b/resend/emails/_email.py @@ -16,6 +16,10 @@ class _EmailDefaultAttrs(_FromParam): """ The Email ID. """ + message_id: str + """ + RFC Message-ID header value for the email. + """ to: Union[List[str], str] """ List of email addresses to send the email to. @@ -60,6 +64,7 @@ class Email(_EmailDefaultAttrs): Attributes: id (str): The Email ID. + message_id (str): RFC Message-ID header value for the email. from (str): The email address the email was sent from. to (Union[List[str], str]): List of email addresses to send the email to. created_at (str): When the email was created. diff --git a/resend/webhooks/_webhook_event.py b/resend/webhooks/_webhook_event.py new file mode 100644 index 0000000..f8605f8 --- /dev/null +++ b/resend/webhooks/_webhook_event.py @@ -0,0 +1,604 @@ +from typing import Dict, List, Union + +from typing_extensions import Literal, NotRequired, TypedDict + + +# Uses functional typed dict syntax here in order to support "from" reserved keyword +_BaseEmailEventDataFromParam = TypedDict( + "_BaseEmailEventDataFromParam", + { + "from": str, + }, +) + + +class BaseEmailEventData(_BaseEmailEventDataFromParam): + """ + Common fields shared by outbound email webhook event payloads. + + Attributes: + created_at (str): ISO 8601 timestamp when the email was created. + email_id (str): Unique identifier for the specific email. + message_id (str): RFC Message-ID header value for the email. + from (str): Sender's email address. + to (List[str]): Array of impacted recipient email addresses. + subject (str): Email subject line. + broadcast_id (NotRequired[str]): Unique identifier for the broadcast campaign. + template_id (NotRequired[str]): Unique identifier for the template used. + tags (NotRequired[Dict[str, str]]): Tag key-value pairs associated with the email. + """ + + created_at: str + """ + ISO 8601 timestamp when the email was created. + """ + email_id: str + """ + Unique identifier for the specific email. + """ + message_id: str + """ + RFC Message-ID header value for the email. + """ + to: List[str] + """ + Array of impacted recipient email addresses. + """ + subject: str + """ + Email subject line. + """ + broadcast_id: NotRequired[str] + """ + Unique identifier for the broadcast campaign. + """ + template_id: NotRequired[str] + """ + Unique identifier for the template used. + """ + tags: NotRequired[Dict[str, str]] + """ + Tag key-value pairs associated with the email. + """ + + +class EmailBounce(TypedDict): + """ + Bounce details from the receiving server. + + Attributes: + message (str): Detailed bounce message from the receiving server. + subType (str): Bounce sub-type (e.g., Suppressed, MessageRejected). + type (str): Bounce type (e.g., Permanent, Temporary). + """ + + message: str + """ + Detailed bounce message from the receiving server. + """ + subType: str + """ + Bounce sub-type (e.g., Suppressed, MessageRejected). + """ + type: str + """ + Bounce type (e.g., Permanent, Temporary). + """ + + +class EmailClick(TypedDict): + """ + Click tracking details. + + Attributes: + ipAddress (str): IP address of the user who clicked the link. + link (str): The URL that was clicked. + timestamp (str): ISO 8601 timestamp when the click occurred. + userAgent (str): User agent string of the browser that clicked the link. + """ + + ipAddress: str + """ + IP address of the user who clicked the link. + """ + link: str + """ + The URL that was clicked. + """ + timestamp: str + """ + ISO 8601 timestamp when the click occurred. + """ + userAgent: str + """ + User agent string of the browser that clicked the link. + """ + + +class EmailFailed(TypedDict): + """ + Failure details for an email that failed to send. + + Attributes: + reason (str): The reason the email failed. + """ + + reason: str + """ + The reason the email failed. + """ + + +class EmailSuppressed(TypedDict): + """ + Suppression details for an email that was not sent. + + Attributes: + message (str): The suppression message. + type (str): The suppression type. + """ + + message: str + """ + The suppression message. + """ + type: str + """ + The suppression type. + """ + + +class ReceivedEmailAttachment(TypedDict): + """ + Attachment metadata included in email.received webhook payloads. + + Attributes: + id (str): The attachment ID. + filename (Union[str, None]): The filename of the attachment. + content_type (str): The content type of the attachment. + content_disposition (Union[str, None]): The content disposition of the attachment. + content_id (Union[str, None]): The content ID for inline attachments. + """ + + id: str + """ + The attachment ID. + """ + filename: Union[str, None] + """ + The filename of the attachment. + """ + content_type: str + """ + The content type of the attachment. + """ + content_disposition: Union[str, None] + """ + The content disposition of the attachment. + """ + content_id: Union[str, None] + """ + The content ID for inline attachments. + """ + + +_ReceivedEmailEventDataFromParam = TypedDict( + "_ReceivedEmailEventDataFromParam", + { + "from": str, + }, +) + + +class ReceivedEmailEventData(_ReceivedEmailEventDataFromParam): + """ + Data payload for email.received webhook events. + + Attributes: + email_id (str): Unique identifier for the specific email. + created_at (str): ISO 8601 timestamp when the email was received. + from (str): Sender's email address. + to (List[str]): Recipient email addresses. + bcc (List[str]): Bcc recipients. + cc (List[str]): Cc recipients. + received_for (List[str]): Addresses the email was received for. + message_id (str): RFC Message-ID header value for the email. + subject (str): Email subject line. + attachments (List[ReceivedEmailAttachment]): Attachment metadata. + """ + + email_id: str + """ + Unique identifier for the specific email. + """ + created_at: str + """ + ISO 8601 timestamp when the email was received. + """ + to: List[str] + """ + Recipient email addresses. + """ + bcc: List[str] + """ + Bcc recipients. + """ + cc: List[str] + """ + Cc recipients. + """ + received_for: List[str] + """ + Addresses the email was received for. + """ + message_id: str + """ + RFC Message-ID header value for the email. + """ + subject: str + """ + Email subject line. + """ + attachments: List[ReceivedEmailAttachment] + """ + Attachment metadata. + """ + + +class ContactEventData(TypedDict): + """ + Data payload for contact webhook events. + + Attributes: + id (str): The contact ID. + audience_id (str): The audience ID. + segment_ids (List[str]): Segment IDs the contact belongs to. + created_at (str): ISO 8601 timestamp when the contact was created. + updated_at (str): ISO 8601 timestamp when the contact was last updated. + email (str): The contact's email address. + unsubscribed (bool): Whether the contact is unsubscribed. + first_name (NotRequired[str]): The contact's first name. + last_name (NotRequired[str]): The contact's last name. + """ + + id: str + """ + The contact ID. + """ + audience_id: str + """ + The audience ID. + """ + segment_ids: List[str] + """ + Segment IDs the contact belongs to. + """ + created_at: str + """ + ISO 8601 timestamp when the contact was created. + """ + updated_at: str + """ + ISO 8601 timestamp when the contact was last updated. + """ + email: str + """ + The contact's email address. + """ + unsubscribed: bool + """ + Whether the contact is unsubscribed. + """ + first_name: NotRequired[str] + """ + The contact's first name. + """ + last_name: NotRequired[str] + """ + The contact's last name. + """ + + +class DomainRecord(TypedDict): + """ + DNS record for a domain. + + Attributes: + record (str): The record identifier. + name (str): The record name. + type (str): The record type. + ttl (str): The record TTL. + status (str): The record status. + value (str): The record value. + priority (NotRequired[int]): The record priority. + """ + + record: str + """ + The record identifier. + """ + name: str + """ + The record name. + """ + type: str + """ + The record type. + """ + ttl: str + """ + The record TTL. + """ + status: str + """ + The record status. + """ + value: str + """ + The record value. + """ + priority: NotRequired[int] + """ + The record priority. + """ + + +class DomainEventData(TypedDict): + """ + Data payload for domain webhook events. + + Attributes: + id (str): The domain ID. + name (str): The domain name. + status (str): The domain status. + created_at (str): ISO 8601 timestamp when the domain was created. + region (str): The domain region. + records (List[DomainRecord]): DNS records for the domain. + """ + + id: str + """ + The domain ID. + """ + name: str + """ + The domain name. + """ + status: str + """ + The domain status. + """ + created_at: str + """ + ISO 8601 timestamp when the domain was created. + """ + region: str + """ + The domain region. + """ + records: List[DomainRecord] + """ + DNS records for the domain. + """ + + +class EmailBouncedEventData(BaseEmailEventData): + bounce: EmailBounce + """ + Bounce details from the receiving server. + """ + + +class EmailClickedEventData(BaseEmailEventData): + click: EmailClick + """ + Click tracking details. + """ + + +class EmailFailedEventData(BaseEmailEventData): + failed: EmailFailed + """ + Failure details for the email. + """ + + +class EmailSuppressedEventData(BaseEmailEventData): + suppressed: EmailSuppressed + """ + Suppression details for the email. + """ + + +class EmailSentEvent(TypedDict): + """ + Webhook payload for email.sent events. + """ + + type: Literal["email.sent"] + created_at: str + data: BaseEmailEventData + + +class EmailScheduledEvent(TypedDict): + """ + Webhook payload for email.scheduled events. + """ + + type: Literal["email.scheduled"] + created_at: str + data: BaseEmailEventData + + +class EmailDeliveredEvent(TypedDict): + """ + Webhook payload for email.delivered events. + """ + + type: Literal["email.delivered"] + created_at: str + data: BaseEmailEventData + + +class EmailDeliveryDelayedEvent(TypedDict): + """ + Webhook payload for email.delivery_delayed events. + """ + + type: Literal["email.delivery_delayed"] + created_at: str + data: BaseEmailEventData + + +class EmailComplainedEvent(TypedDict): + """ + Webhook payload for email.complained events. + """ + + type: Literal["email.complained"] + created_at: str + data: BaseEmailEventData + + +class EmailBouncedEvent(TypedDict): + """ + Webhook payload for email.bounced events. + """ + + type: Literal["email.bounced"] + created_at: str + data: EmailBouncedEventData + + +class EmailOpenedEvent(TypedDict): + """ + Webhook payload for email.opened events. + """ + + type: Literal["email.opened"] + created_at: str + data: BaseEmailEventData + + +class EmailClickedEvent(TypedDict): + """ + Webhook payload for email.clicked events. + """ + + type: Literal["email.clicked"] + created_at: str + data: EmailClickedEventData + + +class EmailReceivedEvent(TypedDict): + """ + Webhook payload for email.received events. + """ + + type: Literal["email.received"] + created_at: str + data: ReceivedEmailEventData + + +class EmailFailedEvent(TypedDict): + """ + Webhook payload for email.failed events. + """ + + type: Literal["email.failed"] + created_at: str + data: EmailFailedEventData + + +class EmailSuppressedEvent(TypedDict): + """ + Webhook payload for email.suppressed events. + """ + + type: Literal["email.suppressed"] + created_at: str + data: EmailSuppressedEventData + + +class ContactCreatedEvent(TypedDict): + """ + Webhook payload for contact.created events. + """ + + type: Literal["contact.created"] + created_at: str + data: ContactEventData + + +class ContactUpdatedEvent(TypedDict): + """ + Webhook payload for contact.updated events. + """ + + type: Literal["contact.updated"] + created_at: str + data: ContactEventData + + +class ContactDeletedEvent(TypedDict): + """ + Webhook payload for contact.deleted events. + """ + + type: Literal["contact.deleted"] + created_at: str + data: ContactEventData + + +class DomainCreatedEvent(TypedDict): + """ + Webhook payload for domain.created events. + """ + + type: Literal["domain.created"] + created_at: str + data: DomainEventData + + +class DomainUpdatedEvent(TypedDict): + """ + Webhook payload for domain.updated events. + """ + + type: Literal["domain.updated"] + created_at: str + data: DomainEventData + + +class DomainDeletedEvent(TypedDict): + """ + Webhook payload for domain.deleted events. + """ + + type: Literal["domain.deleted"] + created_at: str + data: DomainEventData + + +WebhookEventPayload = Union[ + EmailSentEvent, + EmailScheduledEvent, + EmailDeliveredEvent, + EmailDeliveryDelayedEvent, + EmailComplainedEvent, + EmailBouncedEvent, + EmailOpenedEvent, + EmailClickedEvent, + EmailReceivedEvent, + EmailFailedEvent, + EmailSuppressedEvent, + ContactCreatedEvent, + ContactUpdatedEvent, + ContactDeletedEvent, + DomainCreatedEvent, + DomainUpdatedEvent, + DomainDeletedEvent, +] +""" +Union of all webhook event payload types returned by Webhooks.verify. +""" diff --git a/resend/webhooks/_webhooks.py b/resend/webhooks/_webhooks.py index 3fe6674..4535dcf 100644 --- a/resend/webhooks/_webhooks.py +++ b/resend/webhooks/_webhooks.py @@ -1,5 +1,6 @@ import base64 import hmac +import json import time from hashlib import sha256 from typing import Any, Dict, List, Optional, cast @@ -17,6 +18,7 @@ pass from resend.webhooks._webhook import (VerifyWebhookOptions, Webhook, WebhookEvent, WebhookStatus) +from resend.webhooks._webhook_event import WebhookEventPayload # Default tolerance for timestamp validation (5 minutes) DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300 @@ -263,7 +265,7 @@ def remove(cls, webhook_id: str) -> DeleteWebhookResponse: return resp @classmethod - def verify(cls, options: VerifyWebhookOptions) -> None: + def verify(cls, options: VerifyWebhookOptions) -> WebhookEventPayload: """ Verify validates a webhook payload using HMAC-SHA256 signature verification. This implements manual verification without external dependencies. @@ -279,7 +281,7 @@ def verify(cls, options: VerifyWebhookOptions) -> None: ValueError: If verification fails or required parameters are missing Returns: - None: Returns None on successful verification, raises ValueError otherwise + WebhookEventPayload: The verified and parsed webhook event payload """ # Validate required parameters if not options: @@ -350,7 +352,13 @@ def verify(cls, options: VerifyWebhookOptions) -> None: received_signature = parts[1] if hmac.compare_digest(expected_signature, received_signature): - return # Signature matches + try: + return cast( + WebhookEventPayload, + json.loads(options["payload"]), + ) + except json.JSONDecodeError as e: + raise ValueError(f"failed to parse webhook payload: {e}") raise ValueError("no matching signature found") diff --git a/tests/emails_test.py b/tests/emails_test.py index a6d661e..f10d9f3 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -41,6 +41,7 @@ def test_email_get(self) -> None: { "object": "email", "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", + "message_id": "<111-222-333@email.example.com>", "to": ["james@bond.com"], "from": "onboarding@resend.dev", "created_at": "2023-04-03T22:13:42.674981+00:00", @@ -58,6 +59,7 @@ def test_email_get(self) -> None: email_id="4ef9a417-02e9-4d39-ad75-9611e0fcc33c", ) assert email["id"] == "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" + assert email["message_id"] == "<111-222-333@email.example.com>" def test_should_get_email_raise_exception_when_no_content(self) -> None: self.set_mock_json(None) @@ -165,6 +167,7 @@ def test_email_list(self) -> None: "data": [ { "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", + "message_id": "<111-222-333@email.example.com>", "to": ["james@bond.com"], "from": "onboarding@resend.dev", "created_at": "2023-04-03T22:13:42.674981+00:00", @@ -178,6 +181,7 @@ def test_email_list(self) -> None: }, { "id": "5ef9a417-02e9-4d39-ad75-9611e0fcc33d", + "message_id": "<222-333-444@email.example.com>", "to": ["test@example.com"], "from": "hello@resend.dev", "created_at": "2023-04-04T10:15:42.674981+00:00", @@ -199,6 +203,9 @@ def test_email_list(self) -> None: assert len(emails["data"]) == 2 assert emails["has_more"] == True assert emails["data"][0]["id"] == "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" + assert ( + emails["data"][0]["message_id"] == "<111-222-333@email.example.com>" + ) assert emails["data"][1]["id"] == "5ef9a417-02e9-4d39-ad75-9611e0fcc33d" def test_email_list_with_params(self) -> None: diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index 8b695bf..c9e09c8 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -115,7 +115,7 @@ def test_verify_valid_webhook(self) -> None: secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") msg_id = "msg_123" timestamp = str(int(time.time())) - payload = '{"type":"email.sent","data":{"email_id":"123"}}' + payload = '{"type":"email.sent","created_at":"2026-02-22T23:41:12.126Z","data":{"email_id":"123","message_id":"","created_at":"2026-02-22T23:41:11.894719+00:00","from":"onboarding@resend.dev","to":["delivered@resend.dev"],"subject":"Hello"}}' signature = self._generate_test_signature(secret, msg_id, timestamp, payload) @@ -132,14 +132,17 @@ def test_verify_valid_webhook(self) -> None: } # Should not raise an exception - resend.Webhooks.verify(options) + event = resend.Webhooks.verify(options) + assert event["type"] == "email.sent" + assert event["data"]["email_id"] == "123" + assert event["data"]["message_id"] == "" def test_verify_invalid_signature(self) -> None: """Test webhook verification with invalid signature""" secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") msg_id = "msg_123" timestamp = str(int(time.time())) - payload = '{"type":"email.sent","data":{"email_id":"123"}}' + payload = '{"type":"email.sent","created_at":"2026-02-22T23:41:12.126Z","data":{"email_id":"123","message_id":"","created_at":"2026-02-22T23:41:11.894719+00:00","from":"onboarding@resend.dev","to":["delivered@resend.dev"],"subject":"Hello"}}' headers: resend.WebhookHeaders = { "id": msg_id, @@ -162,7 +165,7 @@ def test_verify_expired_timestamp(self) -> None: msg_id = "msg_123" # Timestamp from 10 minutes ago (beyond the 5-minute tolerance) timestamp = str(int(time.time()) - 600) - payload = '{"type":"email.sent","data":{"email_id":"123"}}' + payload = '{"type":"email.sent","created_at":"2026-02-22T23:41:12.126Z","data":{"email_id":"123","message_id":"","created_at":"2026-02-22T23:41:11.894719+00:00","from":"onboarding@resend.dev","to":["delivered@resend.dev"],"subject":"Hello"}}' signature = self._generate_test_signature(secret, msg_id, timestamp, payload) @@ -261,7 +264,7 @@ def test_verify_multiple_signatures(self) -> None: secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") msg_id = "msg_123" timestamp = str(int(time.time())) - payload = '{"type":"email.sent","data":{"email_id":"123"}}' + payload = '{"type":"email.sent","created_at":"2026-02-22T23:41:12.126Z","data":{"email_id":"123","message_id":"","created_at":"2026-02-22T23:41:11.894719+00:00","from":"onboarding@resend.dev","to":["delivered@resend.dev"],"subject":"Hello"}}' valid_signature = self._generate_test_signature( secret, msg_id, timestamp, payload @@ -281,7 +284,9 @@ def test_verify_multiple_signatures(self) -> None: } # Should not raise an exception (finds the valid signature) - resend.Webhooks.verify(options) + event = resend.Webhooks.verify(options) + assert event["type"] == "email.sent" + assert event["data"]["message_id"] == "" def test_verify_tampered_payload(self) -> None: """Test webhook verification with tampered payload""" @@ -312,3 +317,27 @@ def test_verify_tampered_payload(self) -> None: with pytest.raises(ValueError, match="no matching signature found"): resend.Webhooks.verify(options) + + def test_verify_invalid_json_payload(self) -> None: + """Test webhook verification with invalid JSON after valid signature""" + secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") + msg_id = "msg_123" + timestamp = str(int(time.time())) + payload = "not-json" + + signature = self._generate_test_signature(secret, msg_id, timestamp, payload) + + headers: resend.WebhookHeaders = { + "id": msg_id, + "timestamp": timestamp, + "signature": signature, + } + + options: resend.VerifyWebhookOptions = { + "payload": payload, + "headers": headers, + "webhook_secret": secret, + } + + with pytest.raises(ValueError, match="failed to parse webhook payload"): + resend.Webhooks.verify(options) From aeacae84e463783073e5ad13f9250217305a0dde Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 19:02:35 +0000 Subject: [PATCH 2/4] fix: satisfy flake8 N815 for API camelCase webhook fields Co-authored-by: cpenned --- resend/webhooks/_webhook_event.py | 69 +++++++++---------------------- 1 file changed, 19 insertions(+), 50 deletions(-) diff --git a/resend/webhooks/_webhook_event.py b/resend/webhooks/_webhook_event.py index f8605f8..1f5e76b 100644 --- a/resend/webhooks/_webhook_event.py +++ b/resend/webhooks/_webhook_event.py @@ -62,57 +62,26 @@ class BaseEmailEventData(_BaseEmailEventDataFromParam): """ -class EmailBounce(TypedDict): - """ - Bounce details from the receiving server. - - Attributes: - message (str): Detailed bounce message from the receiving server. - subType (str): Bounce sub-type (e.g., Suppressed, MessageRejected). - type (str): Bounce type (e.g., Permanent, Temporary). - """ - - message: str - """ - Detailed bounce message from the receiving server. - """ - subType: str - """ - Bounce sub-type (e.g., Suppressed, MessageRejected). - """ - type: str - """ - Bounce type (e.g., Permanent, Temporary). - """ - - -class EmailClick(TypedDict): - """ - Click tracking details. - - Attributes: - ipAddress (str): IP address of the user who clicked the link. - link (str): The URL that was clicked. - timestamp (str): ISO 8601 timestamp when the click occurred. - userAgent (str): User agent string of the browser that clicked the link. - """ +# API fields use camelCase (subType) to match webhook JSON payloads. +EmailBounce = TypedDict( + "EmailBounce", + { + "message": str, + "subType": str, + "type": str, + }, +) - ipAddress: str - """ - IP address of the user who clicked the link. - """ - link: str - """ - The URL that was clicked. - """ - timestamp: str - """ - ISO 8601 timestamp when the click occurred. - """ - userAgent: str - """ - User agent string of the browser that clicked the link. - """ +# API fields use camelCase (ipAddress, userAgent) to match webhook JSON payloads. +EmailClick = TypedDict( + "EmailClick", + { + "ipAddress": str, + "link": str, + "timestamp": str, + "userAgent": str, + }, +) class EmailFailed(TypedDict): From 408a41fd2ceff3cfc4c4df2d300631c717e34587 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 19:08:22 +0000 Subject: [PATCH 3/4] refactor: scope PR to message_id on Email type only Remove webhook event payload types and Webhooks.verify return type change. Received email types already include message_id. Co-authored-by: cpenned --- resend/__init__.py | 44 --- resend/webhooks/_webhook_event.py | 573 ------------------------------ resend/webhooks/_webhooks.py | 14 +- tests/webhooks_test.py | 41 +-- 4 files changed, 9 insertions(+), 663 deletions(-) delete mode 100644 resend/webhooks/_webhook_event.py diff --git a/resend/__init__.py b/resend/__init__.py index 61d72b5..7b4b4e8 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -57,22 +57,6 @@ from .version import __version__, get_version from .webhooks._webhook import (VerifyWebhookOptions, Webhook, WebhookEvent, WebhookHeaders, WebhookStatus) -from .webhooks._webhook_event import (BaseEmailEventData, ContactCreatedEvent, - ContactDeletedEvent, ContactUpdatedEvent, - ContactEventData, DomainCreatedEvent, - DomainDeletedEvent, DomainEventData, - DomainRecord, DomainUpdatedEvent, - EmailBounce, EmailBouncedEvent, - EmailClickedEvent, EmailClick, - EmailComplainedEvent, EmailDeliveredEvent, - EmailDeliveryDelayedEvent, - EmailFailed, EmailFailedEvent, - EmailOpenedEvent, EmailReceivedEvent, - EmailScheduledEvent, EmailSentEvent, - EmailSuppressed, EmailSuppressedEvent, - ReceivedEmailAttachment, - ReceivedEmailEventData, - WebhookEventPayload) from .webhooks._webhooks import Webhooks # Type for clients that support both sync and async @@ -151,37 +135,9 @@ "Variable", "Webhook", "WebhookEvent", - "WebhookEventPayload", "WebhookHeaders", "WebhookStatus", "VerifyWebhookOptions", - "BaseEmailEventData", - "EmailBounce", - "EmailClick", - "EmailFailed", - "EmailSuppressed", - "ReceivedEmailAttachment", - "ReceivedEmailEventData", - "ContactEventData", - "DomainRecord", - "DomainEventData", - "EmailSentEvent", - "EmailScheduledEvent", - "EmailDeliveredEvent", - "EmailDeliveryDelayedEvent", - "EmailComplainedEvent", - "EmailBouncedEvent", - "EmailOpenedEvent", - "EmailClickedEvent", - "EmailReceivedEvent", - "EmailFailedEvent", - "EmailSuppressedEvent", - "ContactCreatedEvent", - "ContactUpdatedEvent", - "ContactDeletedEvent", - "DomainCreatedEvent", - "DomainUpdatedEvent", - "DomainDeletedEvent", "Topic", "BatchValidationError", "ReceivedEmail", diff --git a/resend/webhooks/_webhook_event.py b/resend/webhooks/_webhook_event.py deleted file mode 100644 index 1f5e76b..0000000 --- a/resend/webhooks/_webhook_event.py +++ /dev/null @@ -1,573 +0,0 @@ -from typing import Dict, List, Union - -from typing_extensions import Literal, NotRequired, TypedDict - - -# Uses functional typed dict syntax here in order to support "from" reserved keyword -_BaseEmailEventDataFromParam = TypedDict( - "_BaseEmailEventDataFromParam", - { - "from": str, - }, -) - - -class BaseEmailEventData(_BaseEmailEventDataFromParam): - """ - Common fields shared by outbound email webhook event payloads. - - Attributes: - created_at (str): ISO 8601 timestamp when the email was created. - email_id (str): Unique identifier for the specific email. - message_id (str): RFC Message-ID header value for the email. - from (str): Sender's email address. - to (List[str]): Array of impacted recipient email addresses. - subject (str): Email subject line. - broadcast_id (NotRequired[str]): Unique identifier for the broadcast campaign. - template_id (NotRequired[str]): Unique identifier for the template used. - tags (NotRequired[Dict[str, str]]): Tag key-value pairs associated with the email. - """ - - created_at: str - """ - ISO 8601 timestamp when the email was created. - """ - email_id: str - """ - Unique identifier for the specific email. - """ - message_id: str - """ - RFC Message-ID header value for the email. - """ - to: List[str] - """ - Array of impacted recipient email addresses. - """ - subject: str - """ - Email subject line. - """ - broadcast_id: NotRequired[str] - """ - Unique identifier for the broadcast campaign. - """ - template_id: NotRequired[str] - """ - Unique identifier for the template used. - """ - tags: NotRequired[Dict[str, str]] - """ - Tag key-value pairs associated with the email. - """ - - -# API fields use camelCase (subType) to match webhook JSON payloads. -EmailBounce = TypedDict( - "EmailBounce", - { - "message": str, - "subType": str, - "type": str, - }, -) - -# API fields use camelCase (ipAddress, userAgent) to match webhook JSON payloads. -EmailClick = TypedDict( - "EmailClick", - { - "ipAddress": str, - "link": str, - "timestamp": str, - "userAgent": str, - }, -) - - -class EmailFailed(TypedDict): - """ - Failure details for an email that failed to send. - - Attributes: - reason (str): The reason the email failed. - """ - - reason: str - """ - The reason the email failed. - """ - - -class EmailSuppressed(TypedDict): - """ - Suppression details for an email that was not sent. - - Attributes: - message (str): The suppression message. - type (str): The suppression type. - """ - - message: str - """ - The suppression message. - """ - type: str - """ - The suppression type. - """ - - -class ReceivedEmailAttachment(TypedDict): - """ - Attachment metadata included in email.received webhook payloads. - - Attributes: - id (str): The attachment ID. - filename (Union[str, None]): The filename of the attachment. - content_type (str): The content type of the attachment. - content_disposition (Union[str, None]): The content disposition of the attachment. - content_id (Union[str, None]): The content ID for inline attachments. - """ - - id: str - """ - The attachment ID. - """ - filename: Union[str, None] - """ - The filename of the attachment. - """ - content_type: str - """ - The content type of the attachment. - """ - content_disposition: Union[str, None] - """ - The content disposition of the attachment. - """ - content_id: Union[str, None] - """ - The content ID for inline attachments. - """ - - -_ReceivedEmailEventDataFromParam = TypedDict( - "_ReceivedEmailEventDataFromParam", - { - "from": str, - }, -) - - -class ReceivedEmailEventData(_ReceivedEmailEventDataFromParam): - """ - Data payload for email.received webhook events. - - Attributes: - email_id (str): Unique identifier for the specific email. - created_at (str): ISO 8601 timestamp when the email was received. - from (str): Sender's email address. - to (List[str]): Recipient email addresses. - bcc (List[str]): Bcc recipients. - cc (List[str]): Cc recipients. - received_for (List[str]): Addresses the email was received for. - message_id (str): RFC Message-ID header value for the email. - subject (str): Email subject line. - attachments (List[ReceivedEmailAttachment]): Attachment metadata. - """ - - email_id: str - """ - Unique identifier for the specific email. - """ - created_at: str - """ - ISO 8601 timestamp when the email was received. - """ - to: List[str] - """ - Recipient email addresses. - """ - bcc: List[str] - """ - Bcc recipients. - """ - cc: List[str] - """ - Cc recipients. - """ - received_for: List[str] - """ - Addresses the email was received for. - """ - message_id: str - """ - RFC Message-ID header value for the email. - """ - subject: str - """ - Email subject line. - """ - attachments: List[ReceivedEmailAttachment] - """ - Attachment metadata. - """ - - -class ContactEventData(TypedDict): - """ - Data payload for contact webhook events. - - Attributes: - id (str): The contact ID. - audience_id (str): The audience ID. - segment_ids (List[str]): Segment IDs the contact belongs to. - created_at (str): ISO 8601 timestamp when the contact was created. - updated_at (str): ISO 8601 timestamp when the contact was last updated. - email (str): The contact's email address. - unsubscribed (bool): Whether the contact is unsubscribed. - first_name (NotRequired[str]): The contact's first name. - last_name (NotRequired[str]): The contact's last name. - """ - - id: str - """ - The contact ID. - """ - audience_id: str - """ - The audience ID. - """ - segment_ids: List[str] - """ - Segment IDs the contact belongs to. - """ - created_at: str - """ - ISO 8601 timestamp when the contact was created. - """ - updated_at: str - """ - ISO 8601 timestamp when the contact was last updated. - """ - email: str - """ - The contact's email address. - """ - unsubscribed: bool - """ - Whether the contact is unsubscribed. - """ - first_name: NotRequired[str] - """ - The contact's first name. - """ - last_name: NotRequired[str] - """ - The contact's last name. - """ - - -class DomainRecord(TypedDict): - """ - DNS record for a domain. - - Attributes: - record (str): The record identifier. - name (str): The record name. - type (str): The record type. - ttl (str): The record TTL. - status (str): The record status. - value (str): The record value. - priority (NotRequired[int]): The record priority. - """ - - record: str - """ - The record identifier. - """ - name: str - """ - The record name. - """ - type: str - """ - The record type. - """ - ttl: str - """ - The record TTL. - """ - status: str - """ - The record status. - """ - value: str - """ - The record value. - """ - priority: NotRequired[int] - """ - The record priority. - """ - - -class DomainEventData(TypedDict): - """ - Data payload for domain webhook events. - - Attributes: - id (str): The domain ID. - name (str): The domain name. - status (str): The domain status. - created_at (str): ISO 8601 timestamp when the domain was created. - region (str): The domain region. - records (List[DomainRecord]): DNS records for the domain. - """ - - id: str - """ - The domain ID. - """ - name: str - """ - The domain name. - """ - status: str - """ - The domain status. - """ - created_at: str - """ - ISO 8601 timestamp when the domain was created. - """ - region: str - """ - The domain region. - """ - records: List[DomainRecord] - """ - DNS records for the domain. - """ - - -class EmailBouncedEventData(BaseEmailEventData): - bounce: EmailBounce - """ - Bounce details from the receiving server. - """ - - -class EmailClickedEventData(BaseEmailEventData): - click: EmailClick - """ - Click tracking details. - """ - - -class EmailFailedEventData(BaseEmailEventData): - failed: EmailFailed - """ - Failure details for the email. - """ - - -class EmailSuppressedEventData(BaseEmailEventData): - suppressed: EmailSuppressed - """ - Suppression details for the email. - """ - - -class EmailSentEvent(TypedDict): - """ - Webhook payload for email.sent events. - """ - - type: Literal["email.sent"] - created_at: str - data: BaseEmailEventData - - -class EmailScheduledEvent(TypedDict): - """ - Webhook payload for email.scheduled events. - """ - - type: Literal["email.scheduled"] - created_at: str - data: BaseEmailEventData - - -class EmailDeliveredEvent(TypedDict): - """ - Webhook payload for email.delivered events. - """ - - type: Literal["email.delivered"] - created_at: str - data: BaseEmailEventData - - -class EmailDeliveryDelayedEvent(TypedDict): - """ - Webhook payload for email.delivery_delayed events. - """ - - type: Literal["email.delivery_delayed"] - created_at: str - data: BaseEmailEventData - - -class EmailComplainedEvent(TypedDict): - """ - Webhook payload for email.complained events. - """ - - type: Literal["email.complained"] - created_at: str - data: BaseEmailEventData - - -class EmailBouncedEvent(TypedDict): - """ - Webhook payload for email.bounced events. - """ - - type: Literal["email.bounced"] - created_at: str - data: EmailBouncedEventData - - -class EmailOpenedEvent(TypedDict): - """ - Webhook payload for email.opened events. - """ - - type: Literal["email.opened"] - created_at: str - data: BaseEmailEventData - - -class EmailClickedEvent(TypedDict): - """ - Webhook payload for email.clicked events. - """ - - type: Literal["email.clicked"] - created_at: str - data: EmailClickedEventData - - -class EmailReceivedEvent(TypedDict): - """ - Webhook payload for email.received events. - """ - - type: Literal["email.received"] - created_at: str - data: ReceivedEmailEventData - - -class EmailFailedEvent(TypedDict): - """ - Webhook payload for email.failed events. - """ - - type: Literal["email.failed"] - created_at: str - data: EmailFailedEventData - - -class EmailSuppressedEvent(TypedDict): - """ - Webhook payload for email.suppressed events. - """ - - type: Literal["email.suppressed"] - created_at: str - data: EmailSuppressedEventData - - -class ContactCreatedEvent(TypedDict): - """ - Webhook payload for contact.created events. - """ - - type: Literal["contact.created"] - created_at: str - data: ContactEventData - - -class ContactUpdatedEvent(TypedDict): - """ - Webhook payload for contact.updated events. - """ - - type: Literal["contact.updated"] - created_at: str - data: ContactEventData - - -class ContactDeletedEvent(TypedDict): - """ - Webhook payload for contact.deleted events. - """ - - type: Literal["contact.deleted"] - created_at: str - data: ContactEventData - - -class DomainCreatedEvent(TypedDict): - """ - Webhook payload for domain.created events. - """ - - type: Literal["domain.created"] - created_at: str - data: DomainEventData - - -class DomainUpdatedEvent(TypedDict): - """ - Webhook payload for domain.updated events. - """ - - type: Literal["domain.updated"] - created_at: str - data: DomainEventData - - -class DomainDeletedEvent(TypedDict): - """ - Webhook payload for domain.deleted events. - """ - - type: Literal["domain.deleted"] - created_at: str - data: DomainEventData - - -WebhookEventPayload = Union[ - EmailSentEvent, - EmailScheduledEvent, - EmailDeliveredEvent, - EmailDeliveryDelayedEvent, - EmailComplainedEvent, - EmailBouncedEvent, - EmailOpenedEvent, - EmailClickedEvent, - EmailReceivedEvent, - EmailFailedEvent, - EmailSuppressedEvent, - ContactCreatedEvent, - ContactUpdatedEvent, - ContactDeletedEvent, - DomainCreatedEvent, - DomainUpdatedEvent, - DomainDeletedEvent, -] -""" -Union of all webhook event payload types returned by Webhooks.verify. -""" diff --git a/resend/webhooks/_webhooks.py b/resend/webhooks/_webhooks.py index 4535dcf..3fe6674 100644 --- a/resend/webhooks/_webhooks.py +++ b/resend/webhooks/_webhooks.py @@ -1,6 +1,5 @@ import base64 import hmac -import json import time from hashlib import sha256 from typing import Any, Dict, List, Optional, cast @@ -18,7 +17,6 @@ pass from resend.webhooks._webhook import (VerifyWebhookOptions, Webhook, WebhookEvent, WebhookStatus) -from resend.webhooks._webhook_event import WebhookEventPayload # Default tolerance for timestamp validation (5 minutes) DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300 @@ -265,7 +263,7 @@ def remove(cls, webhook_id: str) -> DeleteWebhookResponse: return resp @classmethod - def verify(cls, options: VerifyWebhookOptions) -> WebhookEventPayload: + def verify(cls, options: VerifyWebhookOptions) -> None: """ Verify validates a webhook payload using HMAC-SHA256 signature verification. This implements manual verification without external dependencies. @@ -281,7 +279,7 @@ def verify(cls, options: VerifyWebhookOptions) -> WebhookEventPayload: ValueError: If verification fails or required parameters are missing Returns: - WebhookEventPayload: The verified and parsed webhook event payload + None: Returns None on successful verification, raises ValueError otherwise """ # Validate required parameters if not options: @@ -352,13 +350,7 @@ def verify(cls, options: VerifyWebhookOptions) -> WebhookEventPayload: received_signature = parts[1] if hmac.compare_digest(expected_signature, received_signature): - try: - return cast( - WebhookEventPayload, - json.loads(options["payload"]), - ) - except json.JSONDecodeError as e: - raise ValueError(f"failed to parse webhook payload: {e}") + return # Signature matches raise ValueError("no matching signature found") diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index c9e09c8..8b695bf 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -115,7 +115,7 @@ def test_verify_valid_webhook(self) -> None: secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") msg_id = "msg_123" timestamp = str(int(time.time())) - payload = '{"type":"email.sent","created_at":"2026-02-22T23:41:12.126Z","data":{"email_id":"123","message_id":"","created_at":"2026-02-22T23:41:11.894719+00:00","from":"onboarding@resend.dev","to":["delivered@resend.dev"],"subject":"Hello"}}' + payload = '{"type":"email.sent","data":{"email_id":"123"}}' signature = self._generate_test_signature(secret, msg_id, timestamp, payload) @@ -132,17 +132,14 @@ def test_verify_valid_webhook(self) -> None: } # Should not raise an exception - event = resend.Webhooks.verify(options) - assert event["type"] == "email.sent" - assert event["data"]["email_id"] == "123" - assert event["data"]["message_id"] == "" + resend.Webhooks.verify(options) def test_verify_invalid_signature(self) -> None: """Test webhook verification with invalid signature""" secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") msg_id = "msg_123" timestamp = str(int(time.time())) - payload = '{"type":"email.sent","created_at":"2026-02-22T23:41:12.126Z","data":{"email_id":"123","message_id":"","created_at":"2026-02-22T23:41:11.894719+00:00","from":"onboarding@resend.dev","to":["delivered@resend.dev"],"subject":"Hello"}}' + payload = '{"type":"email.sent","data":{"email_id":"123"}}' headers: resend.WebhookHeaders = { "id": msg_id, @@ -165,7 +162,7 @@ def test_verify_expired_timestamp(self) -> None: msg_id = "msg_123" # Timestamp from 10 minutes ago (beyond the 5-minute tolerance) timestamp = str(int(time.time()) - 600) - payload = '{"type":"email.sent","created_at":"2026-02-22T23:41:12.126Z","data":{"email_id":"123","message_id":"","created_at":"2026-02-22T23:41:11.894719+00:00","from":"onboarding@resend.dev","to":["delivered@resend.dev"],"subject":"Hello"}}' + payload = '{"type":"email.sent","data":{"email_id":"123"}}' signature = self._generate_test_signature(secret, msg_id, timestamp, payload) @@ -264,7 +261,7 @@ def test_verify_multiple_signatures(self) -> None: secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") msg_id = "msg_123" timestamp = str(int(time.time())) - payload = '{"type":"email.sent","created_at":"2026-02-22T23:41:12.126Z","data":{"email_id":"123","message_id":"","created_at":"2026-02-22T23:41:11.894719+00:00","from":"onboarding@resend.dev","to":["delivered@resend.dev"],"subject":"Hello"}}' + payload = '{"type":"email.sent","data":{"email_id":"123"}}' valid_signature = self._generate_test_signature( secret, msg_id, timestamp, payload @@ -284,9 +281,7 @@ def test_verify_multiple_signatures(self) -> None: } # Should not raise an exception (finds the valid signature) - event = resend.Webhooks.verify(options) - assert event["type"] == "email.sent" - assert event["data"]["message_id"] == "" + resend.Webhooks.verify(options) def test_verify_tampered_payload(self) -> None: """Test webhook verification with tampered payload""" @@ -317,27 +312,3 @@ def test_verify_tampered_payload(self) -> None: with pytest.raises(ValueError, match="no matching signature found"): resend.Webhooks.verify(options) - - def test_verify_invalid_json_payload(self) -> None: - """Test webhook verification with invalid JSON after valid signature""" - secret = "whsec_" + base64.b64encode(b"test_secret_key").decode("utf-8") - msg_id = "msg_123" - timestamp = str(int(time.time())) - payload = "not-json" - - signature = self._generate_test_signature(secret, msg_id, timestamp, payload) - - headers: resend.WebhookHeaders = { - "id": msg_id, - "timestamp": timestamp, - "signature": signature, - } - - options: resend.VerifyWebhookOptions = { - "payload": payload, - "headers": headers, - "webhook_secret": secret, - } - - with pytest.raises(ValueError, match="failed to parse webhook payload"): - resend.Webhooks.verify(options) From cb4d939f3d5c2af62cf244de282fd00f8e088cce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 19:34:47 +0000 Subject: [PATCH 4/4] style: format emails_test assertion Co-authored-by: cpenned --- tests/emails_test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/emails_test.py b/tests/emails_test.py index f10d9f3..f1363bc 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -203,9 +203,7 @@ def test_email_list(self) -> None: assert len(emails["data"]) == 2 assert emails["has_more"] == True assert emails["data"][0]["id"] == "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" - assert ( - emails["data"][0]["message_id"] == "<111-222-333@email.example.com>" - ) + assert emails["data"][0]["message_id"] == "<111-222-333@email.example.com>" assert emails["data"][1]["id"] == "5ef9a417-02e9-4d39-ad75-9611e0fcc33d" def test_email_list_with_params(self) -> None: