diff --git a/resend/__init__.py b/resend/__init__.py index 2e1c8d5..32e3fa7 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -31,8 +31,9 @@ from .emails._batch import Batch, BatchValidationError from .emails._email import Email from .emails._emails import Emails, EmailTemplate -from .emails._received_email import (EmailAttachment, EmailAttachmentDetails, - ListReceivedEmail, ReceivedEmail) +from .emails._received_email import (AttachmentWithSignedUrl, EmailAttachment, + EmailAttachmentDetails, ListReceivedEmail, + ReceivedEmail) from .emails._receiving import Receiving as EmailsReceiving from .emails._tag import Tag from .events._event import (Event, EventListItem, EventSchema, @@ -136,6 +137,7 @@ "BatchValidationError", "ReceivedEmail", "EmailAttachment", + "AttachmentWithSignedUrl", "EmailAttachmentDetails", "ListReceivedEmail", # Receiving types (for type hints) diff --git a/resend/emails/_attachments.py b/resend/emails/_attachments.py index d28fe13..a351b95 100644 --- a/resend/emails/_attachments.py +++ b/resend/emails/_attachments.py @@ -4,7 +4,7 @@ from resend import request from resend._base_response import BaseResponse -from resend.emails._received_email import (EmailAttachment, +from resend.emails._received_email import (AttachmentWithSignedUrl, EmailAttachmentDetails) from resend.pagination_helper import PaginationHelper @@ -35,7 +35,7 @@ class _ListResponse(BaseResponse): """ The object type: "list" """ - data: List[EmailAttachment] + data: List[AttachmentWithSignedUrl] """ The list of attachment objects. """ @@ -66,7 +66,7 @@ class ListResponse(_ListResponse): Attributes: object (str): The object type: "list" - data (List[EmailAttachment]): The list of attachment objects. + data (List[AttachmentWithSignedUrl]): The list of attachment objects. has_more (bool): Whether there are more attachments available for pagination. """ diff --git a/resend/emails/_received_email.py b/resend/emails/_received_email.py index 41c4f8d..ccc33d1 100644 --- a/resend/emails/_received_email.py +++ b/resend/emails/_received_email.py @@ -1,21 +1,26 @@ from typing import Dict, List, Optional -from typing_extensions import NotRequired, TypedDict +from typing_extensions import Literal, NotRequired, TypedDict from resend._base_response import BaseResponse class EmailAttachment(TypedDict): """ - EmailAttachment type that wraps an attachment object from an email. + Attachment metadata embedded in a received (inbound) email, as returned by + ``Emails.Receiving.get`` and ``Emails.Receiving.list``. + + These are raw values from the inbound MIME parts, so ``filename``, + ``content_id``, and ``content_disposition`` can be null (e.g. S/MIME + signatures or calendar invites), and ``size`` is null in list responses. Attributes: id (str): The attachment ID. filename (Optional[str]): The filename of the attachment. content_type (str): The content type of the attachment. + content_id (Optional[str]): The content ID for inline attachments. content_disposition (Optional[str]): The content disposition of the attachment. - content_id (NotRequired[str]): The content ID for inline attachments. - size (NotRequired[int]): The size of the attachment in bytes. + size (Optional[int]): The size of the attachment in bytes. """ id: str @@ -30,44 +35,41 @@ class EmailAttachment(TypedDict): """ The content type of the attachment. """ - content_disposition: Optional[str] + content_id: Optional[str] """ - The content disposition of the attachment. + The content ID for inline attachments. """ - content_id: NotRequired[str] + content_disposition: Optional[str] """ - The content ID for inline attachments. + The content disposition of the attachment. """ - size: NotRequired[int] + size: Optional[int] """ The size of the attachment in bytes. """ -class EmailAttachmentDetails(TypedDict): +class AttachmentWithSignedUrl(TypedDict): """ - EmailAttachmentDetails type that wraps an email attachment with download details. + Attachment returned by the signed-URL endpoints that list or retrieve + attachments (for both sent and received emails). Attributes: - object (str): The object type. id (str): The attachment ID. - filename (Optional[str]): The filename of the attachment. + filename (NotRequired[str]): The filename of the attachment. content_type (str): The content type of the attachment. - content_disposition (Optional[str]): The content disposition of the attachment. content_id (NotRequired[str]): The content ID for inline attachments. + content_disposition (Literal["inline", "attachment"]): The content disposition of the attachment. + size (int): The size of the attachment in bytes. download_url (str): The URL to download the attachment. expires_at (str): When the download URL expires. """ - object: str - """ - The object type. - """ id: str """ The attachment ID. """ - filename: Optional[str] + filename: NotRequired[str] """ The filename of the attachment. """ @@ -75,13 +77,17 @@ class EmailAttachmentDetails(TypedDict): """ The content type of the attachment. """ - content_disposition: Optional[str] + content_id: NotRequired[str] + """ + The content ID for inline attachments. + """ + content_disposition: Literal["inline", "attachment"] """ The content disposition of the attachment. """ - content_id: NotRequired[str] + size: int """ - The content ID for inline attachments. + The size of the attachment in bytes. """ download_url: str """ @@ -93,6 +99,29 @@ class EmailAttachmentDetails(TypedDict): """ +class EmailAttachmentDetails(AttachmentWithSignedUrl): + """ + A single attachment retrieved from a dedicated attachment endpoint. Same as + ``AttachmentWithSignedUrl`` with an added ``object`` field. + + Attributes: + object (str): The object type. + id (str): The attachment ID. + filename (NotRequired[str]): The filename of the attachment. + content_type (str): The content type of the attachment. + content_id (NotRequired[str]): The content ID for inline attachments. + content_disposition (Literal["inline", "attachment"]): The content disposition of the attachment. + size (int): The size of the attachment in bytes. + download_url (str): The URL to download the attachment. + expires_at (str): When the download URL expires. + """ + + object: str + """ + The object type. + """ + + # Uses functional typed dict syntax here in order to support "from" reserved keyword _ReceivedEmailFromParam = TypedDict( "_ReceivedEmailFromParam", diff --git a/resend/emails/_receiving.py b/resend/emails/_receiving.py index cac815e..00cb101 100644 --- a/resend/emails/_receiving.py +++ b/resend/emails/_receiving.py @@ -4,7 +4,7 @@ from resend import request from resend._base_response import BaseResponse -from resend.emails._received_email import (EmailAttachment, +from resend.emails._received_email import (AttachmentWithSignedUrl, EmailAttachmentDetails, ListReceivedEmail, ReceivedEmail) from resend.pagination_helper import PaginationHelper @@ -66,7 +66,7 @@ class _AttachmentListResponse(BaseResponse): """ The object type: "list" """ - data: List[EmailAttachment] + data: List[AttachmentWithSignedUrl] """ The list of attachment objects. """ @@ -102,7 +102,7 @@ class ListResponse(_AttachmentListResponse): Attributes: object (str): The object type: "list" - data (List[EmailAttachment]): The list of attachment objects. + data (List[AttachmentWithSignedUrl]): The list of attachment objects. has_more (bool): Whether there are more attachments available for pagination. """ diff --git a/tests/attachments_async_test.py b/tests/attachments_async_test.py index ebd0578..44cf282 100644 --- a/tests/attachments_async_test.py +++ b/tests/attachments_async_test.py @@ -56,6 +56,8 @@ async def test_sent_email_attachments_list_async(self) -> None: "content_type": "image/png", "content_disposition": "inline", "size": 1024, + "download_url": "https://cdn.resend.com/emails/test/attachments/test-id", + "expires_at": "2025-10-17T14:29:41.521Z", } ], } diff --git a/tests/attachments_test.py b/tests/attachments_test.py index da041a4..9f99f5b 100644 --- a/tests/attachments_test.py +++ b/tests/attachments_test.py @@ -86,6 +86,8 @@ def test_receiving_list_attachments(self) -> None: "content_disposition": "inline", "content_id": "img001", "size": 1024, + "download_url": "https://inbound-cdn.resend.com/test/attachments/2a0c9ce0?signature=sig-123", + "expires_at": "2025-10-17T14:29:41.521Z", }, { "id": "3b1d0df1-4223-5839-a87f-58eecd27b429", @@ -93,6 +95,8 @@ def test_receiving_list_attachments(self) -> None: "content_type": "application/pdf", "content_disposition": "attachment", "size": 2048, + "download_url": "https://inbound-cdn.resend.com/test/attachments/3b1d0df1?signature=sig-456", + "expires_at": "2025-10-17T14:29:41.521Z", }, ], } @@ -110,6 +114,8 @@ def test_receiving_list_attachments(self) -> None: assert attachments["data"][0]["id"] == "2a0c9ce0-3112-4728-976e-47ddcd16a318" assert attachments["data"][0]["filename"] == "avatar.png" assert attachments["data"][0]["size"] == 1024 + assert "https://inbound-cdn.resend.com" in attachments["data"][0]["download_url"] + assert attachments["data"][0]["expires_at"] == "2025-10-17T14:29:41.521Z" assert attachments["data"][1]["id"] == "3b1d0df1-4223-5839-a87f-58eecd27b429" assert attachments["data"][1]["filename"] == "document.pdf" @@ -125,6 +131,8 @@ def test_receiving_list_attachments_with_pagination(self) -> None: "content_type": "image/png", "content_disposition": "inline", "size": 1024, + "download_url": "https://inbound-cdn.resend.com/test/attachments/2a0c9ce0?signature=sig-123", + "expires_at": "2025-10-17T14:29:41.521Z", }, ], } diff --git a/tests/emails_test.py b/tests/emails_test.py index 6da9b90..cd3bcf0 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -280,6 +280,7 @@ def test_receiving_get(self) -> None: "content_type": "application/pdf", "content_id": "cid_123", "content_disposition": "attachment", + "size": 4096, } ], } @@ -332,6 +333,61 @@ def test_receiving_get_with_no_attachments(self) -> None: assert email["reply_to"] is None assert len(email["attachments"]) == 0 + def test_receiving_get_with_nullable_attachment_fields(self) -> None: + # Inbound MIME parts (S/MIME signatures, calendar invites) can return + # null for filename, content_id, and content_disposition. + # See: https://linear.app/resend/issue/DEV-934 + self.set_mock_json( + { + "object": "inbound", + "id": "67d9bcdb-5a02-42d7-8da9-0d6feea18cff", + "to": ["received@example.com"], + "from": "sender@example.com", + "created_at": "2023-04-07T23:13:52.669661+00:00", + "subject": "Signed inbound email", + "html": None, + "text": "hello world", + "bcc": None, + "cc": None, + "reply_to": None, + "headers": {}, + "message_id": "", + "attachments": [ + { + "id": "f5e32216-3017-4118-97d5-5c84d991bf98", + "filename": "smime.p7s", + "content_type": "application/pkcs7-signature", + "content_id": None, + "content_disposition": "attachment", + "size": 1361, + }, + { + "id": "68136802-3577-4911-a7d2-b303e61261ac", + "filename": None, + "content_type": "text/calendar", + "content_id": None, + "content_disposition": None, + "size": 1152, + }, + ], + } + ) + + email: resend.ReceivedEmail = resend.Emails.Receiving.get( + email_id="67d9bcdb-5a02-42d7-8da9-0d6feea18cff", + ) + assert len(email["attachments"]) == 2 + + smime = email["attachments"][0] + assert smime["filename"] == "smime.p7s" + assert smime["content_disposition"] == "attachment" + assert smime["content_id"] is None + + calendar = email["attachments"][1] + assert calendar["filename"] is None + assert calendar["content_disposition"] is None + assert calendar["content_id"] is None + def test_should_receiving_get_raise_exception_when_no_content(self) -> None: self.set_mock_json(None) with self.assertRaises(NoContentError): diff --git a/tests/receiving_async_test.py b/tests/receiving_async_test.py index 85b1455..ff833ea 100644 --- a/tests/receiving_async_test.py +++ b/tests/receiving_async_test.py @@ -117,6 +117,8 @@ async def test_receiving_attachments_list_async(self) -> None: "content_disposition": "inline", "content_id": "img001", "size": 1024, + "download_url": "https://inbound-cdn.resend.com/test/attachments/2a0c9ce0?signature=sig-123", + "expires_at": "2025-10-17T14:29:41.521Z", }, { "id": "3b1d0df1-4223-5839-a87f-58eecd27b429", @@ -124,6 +126,8 @@ async def test_receiving_attachments_list_async(self) -> None: "content_type": "application/pdf", "content_disposition": "attachment", "size": 2048, + "download_url": "https://inbound-cdn.resend.com/test/attachments/3b1d0df1?signature=sig-456", + "expires_at": "2025-10-17T14:29:41.521Z", }, ], } diff --git a/tests/response_test.py b/tests/response_test.py index 9da85de..32e1d34 100644 --- a/tests/response_test.py +++ b/tests/response_test.py @@ -18,6 +18,8 @@ def test_list_response_supports_dict_access(self) -> None: "content_type": "image/png", "content_disposition": "inline", "size": 1024, + "download_url": "https://cdn.resend.com/att-1", + "expires_at": "2025-10-17T14:29:41.521Z", }, ], } @@ -28,6 +30,7 @@ def test_list_response_supports_dict_access(self) -> None: assert attachments["has_more"] is False assert len(attachments["data"]) == 1 assert attachments["data"][0]["id"] == "att-1" + assert attachments["data"][0]["download_url"] == "https://cdn.resend.com/att-1" def test_list_response_supports_attribute_access(self) -> None: self.set_mock_json( @@ -41,6 +44,8 @@ def test_list_response_supports_attribute_access(self) -> None: "content_type": "image/png", "content_disposition": "inline", "size": 1024, + "download_url": "https://cdn.resend.com/att-1", + "expires_at": "2025-10-17T14:29:41.521Z", }, ], }