Skip to content

Commit da5cefe

Browse files
authored
feat: add mailbox parameter to download_attachment method (#90)
Previously download_attachment was hardcoded to use INBOX, while other methods (get_email_count, get_emails_metadata_stream, get_email_body_by_id, delete_emails) accept a mailbox parameter with INBOX as default. This change adds consistent mailbox parameter support to download_attachment, allowing users to download attachments from any mailbox (e.g., All Mail, Sent, [Gmail]/Sent Mail). Changes: - Add mailbox parameter to EmailClient.download_attachment() with default "INBOX" - Add mailbox parameter to ClassicEmailHandler.download_attachment() - Add mailbox parameter to abstract EmailHandler.download_attachment() - Add mailbox parameter to MCP tool download_attachment() - Add comprehensive tests for mailbox parameter functionality - Update existing test assertions for new parameter
1 parent f64fa61 commit da5cefe

File tree

6 files changed

+129
-8
lines changed

6 files changed

+129
-8
lines changed

mcp_email_server/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ async def download_attachment(
193193
str, Field(description="The name of the attachment to download (as shown in the attachments list).")
194194
],
195195
save_path: Annotated[str, Field(description="The absolute path where the attachment should be saved.")],
196+
mailbox: Annotated[str, Field(description="The mailbox to search in (default: INBOX).")] = "INBOX",
196197
) -> AttachmentDownloadResponse:
197198
settings = get_settings()
198199
if not settings.enable_attachment_download:
@@ -202,4 +203,4 @@ async def download_attachment(
202203
raise PermissionError(msg)
203204

204205
handler = dispatch_handler(account_name)
205-
return await handler.download_attachment(email_id, attachment_name, save_path)
206+
return await handler.download_attachment(email_id, attachment_name, save_path, mailbox)

mcp_email_server/emails/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,17 @@ async def download_attachment(
6161
email_id: str,
6262
attachment_name: str,
6363
save_path: str,
64+
mailbox: str = "INBOX",
6465
) -> "AttachmentDownloadResponse":
6566
"""
66-
Download an email attachment and save it to the specified path
67+
Download an email attachment and save it to the specified path.
68+
69+
Args:
70+
email_id: The UID of the email containing the attachment.
71+
attachment_name: The filename of the attachment to download.
72+
save_path: The local path where the attachment will be saved.
73+
mailbox: The mailbox to search in (default: "INBOX").
74+
75+
Returns:
76+
AttachmentDownloadResponse with download result information.
6777
"""

mcp_email_server/emails/classic.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -443,16 +443,27 @@ async def download_attachment(
443443
email_id: str,
444444
attachment_name: str,
445445
save_path: str,
446+
mailbox: str = "INBOX",
446447
) -> dict[str, Any]:
447-
"""Download a specific attachment from an email and save it to disk."""
448+
"""Download a specific attachment from an email and save it to disk.
449+
450+
Args:
451+
email_id: The UID of the email containing the attachment.
452+
attachment_name: The filename of the attachment to download.
453+
save_path: The local path where the attachment will be saved.
454+
mailbox: The mailbox to search in (default: "INBOX").
455+
456+
Returns:
457+
A dictionary with download result information.
458+
"""
448459
imap = self.imap_class(self.email_server.host, self.email_server.port)
449460
try:
450461
await imap._client_task
451462
await imap.wait_hello_from_server()
452463

453464
await imap.login(self.email_server.user_name, self.email_server.password)
454465
await _send_imap_id(imap)
455-
await imap.select(_quote_mailbox("INBOX"))
466+
await imap.select(_quote_mailbox(mailbox))
456467

457468
data = await self._fetch_email_with_formats(imap, email_id)
458469
if not data:
@@ -846,9 +857,20 @@ async def download_attachment(
846857
email_id: str,
847858
attachment_name: str,
848859
save_path: str,
860+
mailbox: str = "INBOX",
849861
) -> AttachmentDownloadResponse:
850-
"""Download an email attachment and save it to the specified path."""
851-
result = await self.incoming_client.download_attachment(email_id, attachment_name, save_path)
862+
"""Download an email attachment and save it to the specified path.
863+
864+
Args:
865+
email_id: The UID of the email containing the attachment.
866+
attachment_name: The filename of the attachment to download.
867+
save_path: The local path where the attachment will be saved.
868+
mailbox: The mailbox to search in (default: "INBOX").
869+
870+
Returns:
871+
AttachmentDownloadResponse with download result information.
872+
"""
873+
result = await self.incoming_client.download_attachment(email_id, attachment_name, save_path, mailbox)
852874
return AttachmentDownloadResponse(
853875
email_id=result["email_id"],
854876
attachment_name=result["attachment_name"],

tests/test_classic_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ async def test_download_attachment(self, classic_handler, tmp_path):
285285
assert result.size == 1024
286286
assert result.saved_path == save_path
287287

288-
mock_download.assert_called_once_with("123", "document.pdf", save_path)
288+
mock_download.assert_called_once_with("123", "document.pdf", save_path, "INBOX")
289289

290290
@pytest.mark.asyncio
291291
async def test_send_email_with_reply_headers(self, classic_handler):

tests/test_email_attachments.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,91 @@ async def test_mime_type_detection(self, email_client, tmp_path):
210210
message_str = str(message)
211211
for filename in files:
212212
assert filename in message_str
213+
214+
215+
class TestDownloadAttachmentMailboxParam:
216+
"""Tests for download_attachment mailbox parameter."""
217+
218+
@pytest.mark.asyncio
219+
async def test_download_attachment_default_mailbox(self, email_client, tmp_path):
220+
"""Test download_attachment uses INBOX by default."""
221+
import asyncio
222+
223+
save_path = str(tmp_path / "attachment.pdf")
224+
225+
mock_imap = AsyncMock()
226+
mock_imap._client_task = asyncio.Future()
227+
mock_imap._client_task.set_result(None)
228+
mock_imap.wait_hello_from_server = AsyncMock()
229+
mock_imap.login = AsyncMock()
230+
mock_imap.select = AsyncMock(return_value=("OK", [b"1"]))
231+
mock_imap.logout = AsyncMock()
232+
233+
# Mock _fetch_email_with_formats to return None (will raise ValueError)
234+
with patch.object(email_client, "_fetch_email_with_formats", return_value=None):
235+
with patch.object(email_client, "imap_class", return_value=mock_imap):
236+
with pytest.raises(ValueError):
237+
await email_client.download_attachment(
238+
email_id="123",
239+
attachment_name="document.pdf",
240+
save_path=save_path,
241+
)
242+
243+
# Verify select was called with quoted INBOX
244+
mock_imap.select.assert_called_once_with('"INBOX"')
245+
246+
@pytest.mark.asyncio
247+
async def test_download_attachment_custom_mailbox(self, email_client, tmp_path):
248+
"""Test download_attachment with custom mailbox parameter."""
249+
import asyncio
250+
251+
save_path = str(tmp_path / "attachment.pdf")
252+
253+
mock_imap = AsyncMock()
254+
mock_imap._client_task = asyncio.Future()
255+
mock_imap._client_task.set_result(None)
256+
mock_imap.wait_hello_from_server = AsyncMock()
257+
mock_imap.login = AsyncMock()
258+
mock_imap.select = AsyncMock(return_value=("OK", [b"1"]))
259+
mock_imap.logout = AsyncMock()
260+
261+
with patch.object(email_client, "_fetch_email_with_formats", return_value=None):
262+
with patch.object(email_client, "imap_class", return_value=mock_imap):
263+
with pytest.raises(ValueError):
264+
await email_client.download_attachment(
265+
email_id="123",
266+
attachment_name="document.pdf",
267+
save_path=save_path,
268+
mailbox="All Mail",
269+
)
270+
271+
# Verify select was called with quoted custom mailbox
272+
mock_imap.select.assert_called_once_with('"All Mail"')
273+
274+
@pytest.mark.asyncio
275+
async def test_download_attachment_special_folder(self, email_client, tmp_path):
276+
"""Test download_attachment with special folder like [Gmail]/Sent Mail."""
277+
import asyncio
278+
279+
save_path = str(tmp_path / "attachment.pdf")
280+
281+
mock_imap = AsyncMock()
282+
mock_imap._client_task = asyncio.Future()
283+
mock_imap._client_task.set_result(None)
284+
mock_imap.wait_hello_from_server = AsyncMock()
285+
mock_imap.login = AsyncMock()
286+
mock_imap.select = AsyncMock(return_value=("OK", [b"1"]))
287+
mock_imap.logout = AsyncMock()
288+
289+
with patch.object(email_client, "_fetch_email_with_formats", return_value=None):
290+
with patch.object(email_client, "imap_class", return_value=mock_imap):
291+
with pytest.raises(ValueError):
292+
await email_client.download_attachment(
293+
email_id="123",
294+
attachment_name="document.pdf",
295+
save_path=save_path,
296+
mailbox="[Gmail]/Sent Mail",
297+
)
298+
299+
# Verify select was called with quoted special folder
300+
mock_imap.select.assert_called_once_with('"[Gmail]/Sent Mail"')

tests/test_mcp_tools.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ async def test_download_attachment_enabled(self):
480480
assert result.size == 1024
481481

482482
mock_handler.download_attachment.assert_called_once_with(
483-
"12345", "document.pdf", "/var/downloads/document.pdf"
483+
"12345", "document.pdf", "/var/downloads/document.pdf", "INBOX"
484484
)
485485

486486
@pytest.mark.asyncio

0 commit comments

Comments
 (0)