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
6 changes: 4 additions & 2 deletions resend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -136,6 +137,7 @@
"BatchValidationError",
"ReceivedEmail",
"EmailAttachment",
"AttachmentWithSignedUrl",
"EmailAttachmentDetails",
"ListReceivedEmail",
# Receiving types (for type hints)
Expand Down
6 changes: 3 additions & 3 deletions resend/emails/_attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -35,7 +35,7 @@ class _ListResponse(BaseResponse):
"""
The object type: "list"
"""
data: List[EmailAttachment]
data: List[AttachmentWithSignedUrl]
"""
The list of attachment objects.
"""
Expand Down Expand Up @@ -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.
"""

Expand Down
73 changes: 51 additions & 22 deletions resend/emails/_received_email.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -30,58 +35,59 @@ 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.
"""
content_type: str
"""
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
"""
Expand All @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions resend/emails/_receiving.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,7 +66,7 @@ class _AttachmentListResponse(BaseResponse):
"""
The object type: "list"
"""
data: List[EmailAttachment]
data: List[AttachmentWithSignedUrl]
"""
The list of attachment objects.
"""
Expand Down Expand Up @@ -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.
"""

Expand Down
2 changes: 2 additions & 0 deletions tests/attachments_async_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
],
}
Expand Down
8 changes: 8 additions & 0 deletions tests/attachments_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,17 @@
"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",
"filename": "document.pdf",
"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",
},
],
}
Expand All @@ -110,6 +114,8 @@
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"]

Check failure

Code scanning / CodeQL

Incomplete URL substring sanitization High test

The string
https://inbound-cdn.resend.com
may be at an arbitrary position in the sanitized URL.
Comment thread
gabrielmfern marked this conversation as resolved.
Dismissed
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"

Expand All @@ -125,6 +131,8 @@
"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",
},
],
}
Expand Down
56 changes: 56 additions & 0 deletions tests/emails_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ def test_receiving_get(self) -> None:
"content_type": "application/pdf",
"content_id": "cid_123",
"content_disposition": "attachment",
"size": 4096,
}
],
}
Expand Down Expand Up @@ -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": "<msg@example.com>",
"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):
Expand Down
4 changes: 4 additions & 0 deletions tests/receiving_async_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,17 @@ 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",
"filename": "document.pdf",
"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",
},
],
}
Expand Down
5 changes: 5 additions & 0 deletions tests/response_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
}
Expand All @@ -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(
Expand All @@ -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",
},
],
}
Expand Down
Loading