From 7f194a241767ef1e7ce3faba0fa7e1ead71fffa9 Mon Sep 17 00:00:00 2001 From: Jack Koch Date: Mon, 26 Jan 2026 01:25:12 -0500 Subject: [PATCH 1/3] feat: Add mark_emails tool for marking emails as read/unread Add functionality to mark emails as read or unread using IMAP \Seen flag: - EmailMarkResponse model for operation results - mark_emails abstract method in EmailHandler - Implementation in EmailClient and ClassicEmailHandler - MCP tool exposed via app.py Co-Authored-By: Claude Opus 4.5 --- mcp_email_server/app.py | 26 ++++++++++++++ mcp_email_server/emails/__init__.py | 20 +++++++++++ mcp_email_server/emails/classic.py | 56 +++++++++++++++++++++++++++++ mcp_email_server/emails/models.py | 10 ++++++ 4 files changed, 112 insertions(+) diff --git a/mcp_email_server/app.py b/mcp_email_server/app.py index de9f95a..db80102 100644 --- a/mcp_email_server/app.py +++ b/mcp_email_server/app.py @@ -14,6 +14,7 @@ from mcp_email_server.emails.models import ( AttachmentDownloadResponse, EmailContentBatchResponse, + EmailMarkResponse, EmailMetadataPageResponse, ) @@ -196,6 +197,31 @@ async def delete_emails( return result +@mcp.tool( + description="Mark one or more emails as read or unread. Use list_emails_metadata first to get the email_id." +) +async def mark_emails( + account_name: Annotated[str, Field(description="The name of the email account.")], + email_ids: Annotated[ + list[str], + Field(description="List of email_id to mark (obtained from list_emails_metadata)."), + ], + mark_as: Annotated[ + Literal["read", "unread"], + Field(description="Mark emails as 'read' or 'unread'."), + ], + mailbox: Annotated[ + str, + Field( + default="INBOX", + description="IMAP folder path. Standard: INBOX, Sent, Drafts, Trash. Provider-specific: Gmail uses '[Gmail]/...' prefix; ProtonMail Bridge uses 'Folders/' and 'Labels/'.", + ), + ] = "INBOX", +) -> EmailMarkResponse: + handler = dispatch_handler(account_name) + return await handler.mark_emails(email_ids, mark_as, mailbox) + + @mcp.tool( description="Download an email attachment and save it to the specified path. This feature must be explicitly enabled in settings (enable_attachment_download=true) due to security considerations.", ) diff --git a/mcp_email_server/emails/__init__.py b/mcp_email_server/emails/__init__.py index 251e9bc..c00c7c8 100644 --- a/mcp_email_server/emails/__init__.py +++ b/mcp_email_server/emails/__init__.py @@ -6,6 +6,7 @@ from mcp_email_server.emails.models import ( AttachmentDownloadResponse, EmailContentBatchResponse, + EmailMarkResponse, EmailMetadataPageResponse, ) @@ -92,3 +93,22 @@ async def download_attachment( Returns: AttachmentDownloadResponse with download result information. """ + + @abc.abstractmethod + async def mark_emails( + self, + email_ids: list[str], + mark_as: str, + mailbox: str = "INBOX", + ) -> "EmailMarkResponse": + """ + Mark emails as read or unread. + + Args: + email_ids: List of email UIDs to mark. + mark_as: Either "read" or "unread". + mailbox: The mailbox containing the emails (default: "INBOX"). + + Returns: + EmailMarkResponse with operation results. + """ diff --git a/mcp_email_server/emails/classic.py b/mcp_email_server/emails/classic.py index 7c643cd..c96adde 100644 --- a/mcp_email_server/emails/classic.py +++ b/mcp_email_server/emails/classic.py @@ -24,6 +24,7 @@ AttachmentDownloadResponse, EmailBodyResponse, EmailContentBatchResponse, + EmailMarkResponse, EmailMetadata, EmailMetadataPageResponse, ) @@ -912,6 +913,45 @@ async def delete_emails(self, email_ids: list[str], mailbox: str = "INBOX") -> t return deleted_ids, failed_ids + async def mark_emails( + self, email_ids: list[str], mark_as: str, mailbox: str = "INBOX" + ) -> tuple[list[str], list[str]]: + """Mark emails as read or unread. Returns (marked_ids, failed_ids).""" + imap = self.imap_class(self.email_server.host, self.email_server.port) + marked_ids = [] + failed_ids = [] + + # Determine flag operation: +FLAGS for read, -FLAGS for unread + if mark_as == "read": + flag_op = "+FLAGS" + elif mark_as == "unread": + flag_op = "-FLAGS" + else: + raise ValueError(f"Invalid mark_as value: {mark_as}. Must be 'read' or 'unread'.") + + try: + await imap._client_task + await imap.wait_hello_from_server() + await imap.login(self.email_server.user_name, self.email_server.password) + await _send_imap_id(imap) + await imap.select(_quote_mailbox(mailbox)) + + for email_id in email_ids: + try: + await imap.uid("store", email_id, flag_op, r"(\Seen)") + marked_ids.append(email_id) + except Exception as e: + logger.error(f"Failed to mark email {email_id} as {mark_as}: {e}") + failed_ids.append(email_id) + + finally: + try: + await imap.logout() + except Exception as e: + logger.info(f"Error during logout: {e}") + + return marked_ids, failed_ids + class ClassicEmailHandler(EmailHandler): def __init__(self, email_settings: EmailSettings): @@ -1067,3 +1107,19 @@ async def download_attachment( size=result["size"], saved_path=result["saved_path"], ) + + async def mark_emails( + self, + email_ids: list[str], + mark_as: str, + mailbox: str = "INBOX", + ) -> EmailMarkResponse: + """Mark emails as read or unread.""" + marked_ids, failed_ids = await self.incoming_client.mark_emails(email_ids, mark_as, mailbox) + return EmailMarkResponse( + success=len(failed_ids) == 0, + marked_ids=marked_ids, + failed_ids=failed_ids, + mailbox=mailbox, + marked_as=mark_as, + ) diff --git a/mcp_email_server/emails/models.py b/mcp_email_server/emails/models.py index 8c1ee4a..d387825 100644 --- a/mcp_email_server/emails/models.py +++ b/mcp_email_server/emails/models.py @@ -70,3 +70,13 @@ class AttachmentDownloadResponse(BaseModel): mime_type: str size: int saved_path: str + + +class EmailMarkResponse(BaseModel): + """Response for mark_emails operation""" + + success: bool + marked_ids: list[str] + failed_ids: list[str] + mailbox: str + marked_as: str From a240b5295a638f08e69c8f8683b3662bafee07f6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 06:25:30 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mcp_email_server/app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mcp_email_server/app.py b/mcp_email_server/app.py index db80102..5423d11 100644 --- a/mcp_email_server/app.py +++ b/mcp_email_server/app.py @@ -197,9 +197,7 @@ async def delete_emails( return result -@mcp.tool( - description="Mark one or more emails as read or unread. Use list_emails_metadata first to get the email_id." -) +@mcp.tool(description="Mark one or more emails as read or unread. Use list_emails_metadata first to get the email_id.") async def mark_emails( account_name: Annotated[str, Field(description="The name of the email account.")], email_ids: Annotated[ From 5217f64a3a1b20f4854ff2ea9b4706638e333e45 Mon Sep 17 00:00:00 2001 From: Jack Koch Date: Mon, 26 Jan 2026 01:40:20 -0500 Subject: [PATCH 3/3] Test: Add comprehensive tests for mark_emails functionality Add tests covering all code paths in the mark_emails method: - Mark emails as read (using +FLAGS) - Mark emails as unread (using -FLAGS) - Partial failure handling - Invalid mark_as value raises ValueError - Custom mailbox selection - Logout error handling Co-Authored-By: Claude Opus 4.5 --- tests/test_email_client.py | 122 +++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/test_email_client.py b/tests/test_email_client.py index 20343c5..5dbe216 100644 --- a/tests/test_email_client.py +++ b/tests/test_email_client.py @@ -640,3 +640,125 @@ async def test_batch_fetch_headers_preserves_uid_mapping(self, email_client): assert len(result) == 2 assert result["100"]["subject"] == "First" assert result["200"]["subject"] == "Second" + + +class TestMarkEmails: + """Tests for mark_emails functionality.""" + + @pytest.mark.asyncio + async def test_mark_emails_as_read_success(self, email_client): + """Test successfully marking emails as read.""" + mock_imap = AsyncMock() + mock_imap._client_task = asyncio.Future() + mock_imap._client_task.set_result(None) + mock_imap.wait_hello_from_server = AsyncMock() + mock_imap.login = AsyncMock() + mock_imap.select = AsyncMock() + mock_imap.uid = AsyncMock(return_value=(None, None)) + mock_imap.logout = AsyncMock() + + with patch.object(email_client, "imap_class", return_value=mock_imap): + marked_ids, failed_ids = await email_client.mark_emails(["123", "456"], "read") + + assert marked_ids == ["123", "456"] + assert failed_ids == [] + # Verify +FLAGS was used for read + calls = mock_imap.uid.call_args_list + assert calls[0][0] == ("store", "123", "+FLAGS", r"(\Seen)") + assert calls[1][0] == ("store", "456", "+FLAGS", r"(\Seen)") + + @pytest.mark.asyncio + async def test_mark_emails_as_unread_success(self, email_client): + """Test successfully marking emails as unread.""" + mock_imap = AsyncMock() + mock_imap._client_task = asyncio.Future() + mock_imap._client_task.set_result(None) + mock_imap.wait_hello_from_server = AsyncMock() + mock_imap.login = AsyncMock() + mock_imap.select = AsyncMock() + mock_imap.uid = AsyncMock(return_value=(None, None)) + mock_imap.logout = AsyncMock() + + with patch.object(email_client, "imap_class", return_value=mock_imap): + marked_ids, failed_ids = await email_client.mark_emails(["123"], "unread") + + assert marked_ids == ["123"] + assert failed_ids == [] + # Verify -FLAGS was used for unread + mock_imap.uid.assert_called_with("store", "123", "-FLAGS", r"(\Seen)") + + @pytest.mark.asyncio + async def test_mark_emails_partial_failure(self, email_client): + """Test mark_emails with some failures.""" + mock_imap = AsyncMock() + mock_imap._client_task = asyncio.Future() + mock_imap._client_task.set_result(None) + mock_imap.wait_hello_from_server = AsyncMock() + mock_imap.login = AsyncMock() + mock_imap.select = AsyncMock() + mock_imap.logout = AsyncMock() + + call_count = [0] + + def uid_side_effect(*args): + call_count[0] += 1 + if call_count[0] == 1: + return (None, None) + else: + raise OSError("IMAP error") + + mock_imap.uid = AsyncMock(side_effect=uid_side_effect) + + with patch.object(email_client, "imap_class", return_value=mock_imap): + marked_ids, failed_ids = await email_client.mark_emails(["123", "456"], "read") + + assert marked_ids == ["123"] + assert failed_ids == ["456"] + + @pytest.mark.asyncio + async def test_mark_emails_invalid_mark_as_value(self, email_client): + """Test mark_emails with invalid mark_as value raises ValueError.""" + mock_imap = AsyncMock() + mock_imap._client_task = asyncio.Future() + mock_imap._client_task.set_result(None) + + with patch.object(email_client, "imap_class", return_value=mock_imap): + with pytest.raises(ValueError, match="Invalid mark_as value"): + await email_client.mark_emails(["123"], "invalid") + + @pytest.mark.asyncio + async def test_mark_emails_custom_mailbox(self, email_client): + """Test mark_emails with custom mailbox.""" + mock_imap = AsyncMock() + mock_imap._client_task = asyncio.Future() + mock_imap._client_task.set_result(None) + mock_imap.wait_hello_from_server = AsyncMock() + mock_imap.login = AsyncMock() + mock_imap.select = AsyncMock() + mock_imap.uid = AsyncMock(return_value=(None, None)) + mock_imap.logout = AsyncMock() + + with patch.object(email_client, "imap_class", return_value=mock_imap): + await email_client.mark_emails(["123"], "read", mailbox="Archive") + + # Verify the custom mailbox was selected + mock_imap.select.assert_called_once_with('"Archive"') + + @pytest.mark.asyncio + async def test_mark_emails_logout_error(self, email_client): + """Test mark_emails handles logout errors gracefully.""" + mock_imap = AsyncMock() + mock_imap._client_task = asyncio.Future() + mock_imap._client_task.set_result(None) + mock_imap.wait_hello_from_server = AsyncMock() + mock_imap.login = AsyncMock() + mock_imap.select = AsyncMock() + mock_imap.uid = AsyncMock(return_value=(None, None)) + mock_imap.logout = AsyncMock(side_effect=Exception("Logout failed")) + + with patch.object(email_client, "imap_class", return_value=mock_imap): + # Should not raise, just log the error + marked_ids, failed_ids = await email_client.mark_emails(["123"], "read") + + assert marked_ids == ["123"] + assert failed_ids == []