Skip to content
Open
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
24 changes: 24 additions & 0 deletions mcp_email_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from mcp_email_server.emails.models import (
AttachmentDownloadResponse,
EmailContentBatchResponse,
EmailMarkResponse,
EmailMetadataPageResponse,
)

Expand Down Expand Up @@ -196,6 +197,29 @@ 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/<name>' and 'Labels/<name>'.",
),
] = "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.",
)
Expand Down
20 changes: 20 additions & 0 deletions mcp_email_server/emails/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from mcp_email_server.emails.models import (
AttachmentDownloadResponse,
EmailContentBatchResponse,
EmailMarkResponse,
EmailMetadataPageResponse,
)

Expand Down Expand Up @@ -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.
"""
56 changes: 56 additions & 0 deletions mcp_email_server/emails/classic.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
AttachmentDownloadResponse,
EmailBodyResponse,
EmailContentBatchResponse,
EmailMarkResponse,
EmailMetadata,
EmailMetadataPageResponse,
)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
)
10 changes: 10 additions & 0 deletions mcp_email_server/emails/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
122 changes: 122 additions & 0 deletions tests/test_email_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []
Loading