Skip to content

Commit cb3726c

Browse files
authored
fix: inbound attachment types (#211)
Signed-off-by: gabriel miranda <gabrielmfern@outlook.com>
1 parent 5fb1ac5 commit cb3726c

9 files changed

Lines changed: 136 additions & 30 deletions

resend/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@
3131
from .emails._batch import Batch, BatchValidationError
3232
from .emails._email import Email
3333
from .emails._emails import Emails, EmailTemplate
34-
from .emails._received_email import (EmailAttachment, EmailAttachmentDetails,
35-
ListReceivedEmail, ReceivedEmail)
34+
from .emails._received_email import (AttachmentWithSignedUrl, EmailAttachment,
35+
EmailAttachmentDetails, ListReceivedEmail,
36+
ReceivedEmail)
3637
from .emails._receiving import Receiving as EmailsReceiving
3738
from .emails._tag import Tag
3839
from .events._event import (Event, EventListItem, EventSchema,
@@ -136,6 +137,7 @@
136137
"BatchValidationError",
137138
"ReceivedEmail",
138139
"EmailAttachment",
140+
"AttachmentWithSignedUrl",
139141
"EmailAttachmentDetails",
140142
"ListReceivedEmail",
141143
# Receiving types (for type hints)

resend/emails/_attachments.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from resend import request
66
from resend._base_response import BaseResponse
7-
from resend.emails._received_email import (EmailAttachment,
7+
from resend.emails._received_email import (AttachmentWithSignedUrl,
88
EmailAttachmentDetails)
99
from resend.pagination_helper import PaginationHelper
1010

@@ -35,7 +35,7 @@ class _ListResponse(BaseResponse):
3535
"""
3636
The object type: "list"
3737
"""
38-
data: List[EmailAttachment]
38+
data: List[AttachmentWithSignedUrl]
3939
"""
4040
The list of attachment objects.
4141
"""
@@ -66,7 +66,7 @@ class ListResponse(_ListResponse):
6666
6767
Attributes:
6868
object (str): The object type: "list"
69-
data (List[EmailAttachment]): The list of attachment objects.
69+
data (List[AttachmentWithSignedUrl]): The list of attachment objects.
7070
has_more (bool): Whether there are more attachments available for pagination.
7171
"""
7272

resend/emails/_received_email.py

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
from typing import Dict, List, Optional
22

3-
from typing_extensions import NotRequired, TypedDict
3+
from typing_extensions import Literal, NotRequired, TypedDict
44

55
from resend._base_response import BaseResponse
66

77

88
class EmailAttachment(TypedDict):
99
"""
10-
EmailAttachment type that wraps an attachment object from an email.
10+
Attachment metadata embedded in a received (inbound) email, as returned by
11+
``Emails.Receiving.get`` and ``Emails.Receiving.list``.
12+
13+
These are raw values from the inbound MIME parts, so ``filename``,
14+
``content_id``, and ``content_disposition`` can be null (e.g. S/MIME
15+
signatures or calendar invites), and ``size`` is null in list responses.
1116
1217
Attributes:
1318
id (str): The attachment ID.
1419
filename (Optional[str]): The filename of the attachment.
1520
content_type (str): The content type of the attachment.
21+
content_id (Optional[str]): The content ID for inline attachments.
1622
content_disposition (Optional[str]): The content disposition of the attachment.
17-
content_id (NotRequired[str]): The content ID for inline attachments.
18-
size (NotRequired[int]): The size of the attachment in bytes.
23+
size (Optional[int]): The size of the attachment in bytes.
1924
"""
2025

2126
id: str
@@ -30,58 +35,59 @@ class EmailAttachment(TypedDict):
3035
"""
3136
The content type of the attachment.
3237
"""
33-
content_disposition: Optional[str]
38+
content_id: Optional[str]
3439
"""
35-
The content disposition of the attachment.
40+
The content ID for inline attachments.
3641
"""
37-
content_id: NotRequired[str]
42+
content_disposition: Optional[str]
3843
"""
39-
The content ID for inline attachments.
44+
The content disposition of the attachment.
4045
"""
41-
size: NotRequired[int]
46+
size: Optional[int]
4247
"""
4348
The size of the attachment in bytes.
4449
"""
4550

4651

47-
class EmailAttachmentDetails(TypedDict):
52+
class AttachmentWithSignedUrl(TypedDict):
4853
"""
49-
EmailAttachmentDetails type that wraps an email attachment with download details.
54+
Attachment returned by the signed-URL endpoints that list or retrieve
55+
attachments (for both sent and received emails).
5056
5157
Attributes:
52-
object (str): The object type.
5358
id (str): The attachment ID.
54-
filename (Optional[str]): The filename of the attachment.
59+
filename (NotRequired[str]): The filename of the attachment.
5560
content_type (str): The content type of the attachment.
56-
content_disposition (Optional[str]): The content disposition of the attachment.
5761
content_id (NotRequired[str]): The content ID for inline attachments.
62+
content_disposition (Literal["inline", "attachment"]): The content disposition of the attachment.
63+
size (int): The size of the attachment in bytes.
5864
download_url (str): The URL to download the attachment.
5965
expires_at (str): When the download URL expires.
6066
"""
6167

62-
object: str
63-
"""
64-
The object type.
65-
"""
6668
id: str
6769
"""
6870
The attachment ID.
6971
"""
70-
filename: Optional[str]
72+
filename: NotRequired[str]
7173
"""
7274
The filename of the attachment.
7375
"""
7476
content_type: str
7577
"""
7678
The content type of the attachment.
7779
"""
78-
content_disposition: Optional[str]
80+
content_id: NotRequired[str]
81+
"""
82+
The content ID for inline attachments.
83+
"""
84+
content_disposition: Literal["inline", "attachment"]
7985
"""
8086
The content disposition of the attachment.
8187
"""
82-
content_id: NotRequired[str]
88+
size: int
8389
"""
84-
The content ID for inline attachments.
90+
The size of the attachment in bytes.
8591
"""
8692
download_url: str
8793
"""
@@ -93,6 +99,29 @@ class EmailAttachmentDetails(TypedDict):
9399
"""
94100

95101

102+
class EmailAttachmentDetails(AttachmentWithSignedUrl):
103+
"""
104+
A single attachment retrieved from a dedicated attachment endpoint. Same as
105+
``AttachmentWithSignedUrl`` with an added ``object`` field.
106+
107+
Attributes:
108+
object (str): The object type.
109+
id (str): The attachment ID.
110+
filename (NotRequired[str]): The filename of the attachment.
111+
content_type (str): The content type of the attachment.
112+
content_id (NotRequired[str]): The content ID for inline attachments.
113+
content_disposition (Literal["inline", "attachment"]): The content disposition of the attachment.
114+
size (int): The size of the attachment in bytes.
115+
download_url (str): The URL to download the attachment.
116+
expires_at (str): When the download URL expires.
117+
"""
118+
119+
object: str
120+
"""
121+
The object type.
122+
"""
123+
124+
96125
# Uses functional typed dict syntax here in order to support "from" reserved keyword
97126
_ReceivedEmailFromParam = TypedDict(
98127
"_ReceivedEmailFromParam",

resend/emails/_receiving.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from resend import request
66
from resend._base_response import BaseResponse
7-
from resend.emails._received_email import (EmailAttachment,
7+
from resend.emails._received_email import (AttachmentWithSignedUrl,
88
EmailAttachmentDetails,
99
ListReceivedEmail, ReceivedEmail)
1010
from resend.pagination_helper import PaginationHelper
@@ -66,7 +66,7 @@ class _AttachmentListResponse(BaseResponse):
6666
"""
6767
The object type: "list"
6868
"""
69-
data: List[EmailAttachment]
69+
data: List[AttachmentWithSignedUrl]
7070
"""
7171
The list of attachment objects.
7272
"""
@@ -102,7 +102,7 @@ class ListResponse(_AttachmentListResponse):
102102
103103
Attributes:
104104
object (str): The object type: "list"
105-
data (List[EmailAttachment]): The list of attachment objects.
105+
data (List[AttachmentWithSignedUrl]): The list of attachment objects.
106106
has_more (bool): Whether there are more attachments available for pagination.
107107
"""
108108

tests/attachments_async_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ async def test_sent_email_attachments_list_async(self) -> None:
5656
"content_type": "image/png",
5757
"content_disposition": "inline",
5858
"size": 1024,
59+
"download_url": "https://cdn.resend.com/emails/test/attachments/test-id",
60+
"expires_at": "2025-10-17T14:29:41.521Z",
5961
}
6062
],
6163
}

tests/attachments_test.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,17 @@ def test_receiving_list_attachments(self) -> None:
8686
"content_disposition": "inline",
8787
"content_id": "img001",
8888
"size": 1024,
89+
"download_url": "https://inbound-cdn.resend.com/test/attachments/2a0c9ce0?signature=sig-123",
90+
"expires_at": "2025-10-17T14:29:41.521Z",
8991
},
9092
{
9193
"id": "3b1d0df1-4223-5839-a87f-58eecd27b429",
9294
"filename": "document.pdf",
9395
"content_type": "application/pdf",
9496
"content_disposition": "attachment",
9597
"size": 2048,
98+
"download_url": "https://inbound-cdn.resend.com/test/attachments/3b1d0df1?signature=sig-456",
99+
"expires_at": "2025-10-17T14:29:41.521Z",
96100
},
97101
],
98102
}
@@ -110,6 +114,8 @@ def test_receiving_list_attachments(self) -> None:
110114
assert attachments["data"][0]["id"] == "2a0c9ce0-3112-4728-976e-47ddcd16a318"
111115
assert attachments["data"][0]["filename"] == "avatar.png"
112116
assert attachments["data"][0]["size"] == 1024
117+
assert "https://inbound-cdn.resend.com" in attachments["data"][0]["download_url"]
118+
assert attachments["data"][0]["expires_at"] == "2025-10-17T14:29:41.521Z"
113119
assert attachments["data"][1]["id"] == "3b1d0df1-4223-5839-a87f-58eecd27b429"
114120
assert attachments["data"][1]["filename"] == "document.pdf"
115121

@@ -125,6 +131,8 @@ def test_receiving_list_attachments_with_pagination(self) -> None:
125131
"content_type": "image/png",
126132
"content_disposition": "inline",
127133
"size": 1024,
134+
"download_url": "https://inbound-cdn.resend.com/test/attachments/2a0c9ce0?signature=sig-123",
135+
"expires_at": "2025-10-17T14:29:41.521Z",
128136
},
129137
],
130138
}

tests/emails_test.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ def test_receiving_get(self) -> None:
280280
"content_type": "application/pdf",
281281
"content_id": "cid_123",
282282
"content_disposition": "attachment",
283+
"size": 4096,
283284
}
284285
],
285286
}
@@ -332,6 +333,61 @@ def test_receiving_get_with_no_attachments(self) -> None:
332333
assert email["reply_to"] is None
333334
assert len(email["attachments"]) == 0
334335

336+
def test_receiving_get_with_nullable_attachment_fields(self) -> None:
337+
# Inbound MIME parts (S/MIME signatures, calendar invites) can return
338+
# null for filename, content_id, and content_disposition.
339+
# See: https://linear.app/resend/issue/DEV-934
340+
self.set_mock_json(
341+
{
342+
"object": "inbound",
343+
"id": "67d9bcdb-5a02-42d7-8da9-0d6feea18cff",
344+
"to": ["received@example.com"],
345+
"from": "sender@example.com",
346+
"created_at": "2023-04-07T23:13:52.669661+00:00",
347+
"subject": "Signed inbound email",
348+
"html": None,
349+
"text": "hello world",
350+
"bcc": None,
351+
"cc": None,
352+
"reply_to": None,
353+
"headers": {},
354+
"message_id": "<msg@example.com>",
355+
"attachments": [
356+
{
357+
"id": "f5e32216-3017-4118-97d5-5c84d991bf98",
358+
"filename": "smime.p7s",
359+
"content_type": "application/pkcs7-signature",
360+
"content_id": None,
361+
"content_disposition": "attachment",
362+
"size": 1361,
363+
},
364+
{
365+
"id": "68136802-3577-4911-a7d2-b303e61261ac",
366+
"filename": None,
367+
"content_type": "text/calendar",
368+
"content_id": None,
369+
"content_disposition": None,
370+
"size": 1152,
371+
},
372+
],
373+
}
374+
)
375+
376+
email: resend.ReceivedEmail = resend.Emails.Receiving.get(
377+
email_id="67d9bcdb-5a02-42d7-8da9-0d6feea18cff",
378+
)
379+
assert len(email["attachments"]) == 2
380+
381+
smime = email["attachments"][0]
382+
assert smime["filename"] == "smime.p7s"
383+
assert smime["content_disposition"] == "attachment"
384+
assert smime["content_id"] is None
385+
386+
calendar = email["attachments"][1]
387+
assert calendar["filename"] is None
388+
assert calendar["content_disposition"] is None
389+
assert calendar["content_id"] is None
390+
335391
def test_should_receiving_get_raise_exception_when_no_content(self) -> None:
336392
self.set_mock_json(None)
337393
with self.assertRaises(NoContentError):

tests/receiving_async_test.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,17 @@ async def test_receiving_attachments_list_async(self) -> None:
117117
"content_disposition": "inline",
118118
"content_id": "img001",
119119
"size": 1024,
120+
"download_url": "https://inbound-cdn.resend.com/test/attachments/2a0c9ce0?signature=sig-123",
121+
"expires_at": "2025-10-17T14:29:41.521Z",
120122
},
121123
{
122124
"id": "3b1d0df1-4223-5839-a87f-58eecd27b429",
123125
"filename": "document.pdf",
124126
"content_type": "application/pdf",
125127
"content_disposition": "attachment",
126128
"size": 2048,
129+
"download_url": "https://inbound-cdn.resend.com/test/attachments/3b1d0df1?signature=sig-456",
130+
"expires_at": "2025-10-17T14:29:41.521Z",
127131
},
128132
],
129133
}

tests/response_test.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ def test_list_response_supports_dict_access(self) -> None:
1818
"content_type": "image/png",
1919
"content_disposition": "inline",
2020
"size": 1024,
21+
"download_url": "https://cdn.resend.com/att-1",
22+
"expires_at": "2025-10-17T14:29:41.521Z",
2123
},
2224
],
2325
}
@@ -28,6 +30,7 @@ def test_list_response_supports_dict_access(self) -> None:
2830
assert attachments["has_more"] is False
2931
assert len(attachments["data"]) == 1
3032
assert attachments["data"][0]["id"] == "att-1"
33+
assert attachments["data"][0]["download_url"] == "https://cdn.resend.com/att-1"
3134

3235
def test_list_response_supports_attribute_access(self) -> None:
3336
self.set_mock_json(
@@ -41,6 +44,8 @@ def test_list_response_supports_attribute_access(self) -> None:
4144
"content_type": "image/png",
4245
"content_disposition": "inline",
4346
"size": 1024,
47+
"download_url": "https://cdn.resend.com/att-1",
48+
"expires_at": "2025-10-17T14:29:41.521Z",
4449
},
4550
],
4651
}

0 commit comments

Comments
 (0)