Skip to content

Commit b2c3298

Browse files
WxAtwooclaude
authored andcommitted
Merge PR #116 (email management tools) with PR #117 (search optimization + filter validation)
Combined optimizations: - PR #116: Add list_mailboxes, move_emails, mark_emails_as_read, search_emails tools - PR #116: UID-based pagination optimization (60s+ → <5s on large mailboxes) - PR #117: Filter validation (prevents accidental expensive searches) - PR #117: Search result caching (_last_search_total) Conflict resolution: Merged parse_search_response logic with caching to avoid duplicate searches. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2 parents ca1beef + 478729d commit b2c3298

File tree

5 files changed

+593
-67
lines changed

5 files changed

+593
-67
lines changed

mcp_email_server/app.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,35 @@ async def list_available_accounts() -> list[AccountAttributes]:
3232
return [account.masked() for account in settings.get_accounts()]
3333

3434

35+
@mcp.tool(
36+
description="List all mailboxes (folders) in an email account. Use this to discover available folders like Archive, Sent, Trash, etc."
37+
)
38+
async def list_mailboxes(
39+
account_name: Annotated[str, Field(description="The name of the email account.")],
40+
) -> list[dict]:
41+
handler = dispatch_handler(account_name)
42+
return await handler.list_mailboxes()
43+
44+
45+
@mcp.tool(
46+
description="Search emails using server-side IMAP search. Fast even with thousands of emails. "
47+
"Searches in subject, body, and headers by default."
48+
)
49+
async def search_emails(
50+
account_name: Annotated[str, Field(description="The name of the email account.")],
51+
query: Annotated[str, Field(description="Text to search for in emails.")],
52+
mailbox: Annotated[str, Field(default="INBOX", description="Mailbox to search in.")] = "INBOX",
53+
search_in: Annotated[
54+
Literal["all", "subject", "body", "from"],
55+
Field(default="all", description="Where to search: 'all' (headers+body), 'subject', 'body', or 'from'."),
56+
] = "all",
57+
page: Annotated[int, Field(default=1, description="Page number (starting from 1).")] = 1,
58+
page_size: Annotated[int, Field(default=20, description="Number of results per page.")] = 20,
59+
) -> dict:
60+
handler = dispatch_handler(account_name)
61+
return await handler.search_emails(query, mailbox, search_in, page, page_size)
62+
63+
3564
@mcp.tool(description="Add a new email account configuration to the settings.")
3665
async def add_email_account(email: EmailSettings) -> str:
3766
settings = get_settings()
@@ -196,6 +225,50 @@ async def delete_emails(
196225
return result
197226

198227

228+
@mcp.tool(description="Mark one or more emails as read or unread. Use list_emails_metadata first to get the email_id.")
229+
async def mark_emails_as_read(
230+
account_name: Annotated[str, Field(description="The name of the email account.")],
231+
email_ids: Annotated[
232+
list[str],
233+
Field(description="List of email_id to mark (obtained from list_emails_metadata)."),
234+
],
235+
mailbox: Annotated[str, Field(default="INBOX", description="The mailbox containing the emails.")] = "INBOX",
236+
read: Annotated[bool, Field(default=True, description="True to mark as read, False to mark as unread.")] = True,
237+
) -> str:
238+
handler = dispatch_handler(account_name)
239+
success_ids, failed_ids = await handler.mark_emails_as_read(email_ids, mailbox, read)
240+
241+
status = "read" if read else "unread"
242+
result = f"Successfully marked {len(success_ids)} email(s) as {status}"
243+
if failed_ids:
244+
result += f", failed to mark {len(failed_ids)} email(s): {', '.join(failed_ids)}"
245+
return result
246+
247+
248+
@mcp.tool(
249+
description="Move one or more emails to a different mailbox/folder. Common destinations: 'Archive', 'Trash', 'Spam'. Use list_emails_metadata first to get the email_id."
250+
)
251+
async def move_emails(
252+
account_name: Annotated[str, Field(description="The name of the email account.")],
253+
email_ids: Annotated[
254+
list[str],
255+
Field(description="List of email_id to move (obtained from list_emails_metadata)."),
256+
],
257+
destination_mailbox: Annotated[
258+
str,
259+
Field(description="Target mailbox name (e.g., 'Archive', 'Trash', 'Spam', '[Gmail]/All Mail')."),
260+
],
261+
source_mailbox: Annotated[str, Field(default="INBOX", description="Source mailbox.")] = "INBOX",
262+
) -> str:
263+
handler = dispatch_handler(account_name)
264+
moved_ids, failed_ids = await handler.move_emails(email_ids, destination_mailbox, source_mailbox)
265+
266+
result = f"Successfully moved {len(moved_ids)} email(s) to '{destination_mailbox}'"
267+
if failed_ids:
268+
result += f", failed to move {len(failed_ids)} email(s): {', '.join(failed_ids)}"
269+
return result
270+
271+
199272
@mcp.tool(
200273
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.",
201274
)

mcp_email_server/emails/__init__.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,70 @@ async def send_email(
7979
references: Space-separated Message-IDs for the thread chain.
8080
"""
8181

82+
@abc.abstractmethod
83+
async def list_mailboxes(self) -> list[dict]:
84+
"""
85+
List all mailboxes (folders) in the email account.
86+
87+
Returns:
88+
List of dictionaries with mailbox info (name, flags, delimiter).
89+
"""
90+
91+
@abc.abstractmethod
92+
async def search_emails(
93+
self,
94+
query: str,
95+
mailbox: str = "INBOX",
96+
search_in: str = "all",
97+
page: int = 1,
98+
page_size: int = 20,
99+
) -> dict:
100+
"""
101+
Search emails using server-side IMAP SEARCH.
102+
103+
Args:
104+
query: Text to search for.
105+
mailbox: Mailbox to search in (default: "INBOX").
106+
search_in: Where to search - "all", "subject", "body", "from".
107+
page: Page number (starting from 1).
108+
page_size: Number of results per page.
109+
110+
Returns:
111+
Dictionary with query, total, page, and emails list.
112+
"""
113+
82114
@abc.abstractmethod
83115
async def delete_emails(self, email_ids: list[str], mailbox: str = "INBOX") -> tuple[list[str], list[str]]:
84116
"""
85117
Delete emails by their IDs. Returns (deleted_ids, failed_ids)
86118
"""
87119

120+
@abc.abstractmethod
121+
async def mark_emails_as_read(
122+
self, email_ids: list[str], mailbox: str = "INBOX", read: bool = True
123+
) -> tuple[list[str], list[str]]:
124+
"""
125+
Mark emails as read or unread. Returns (success_ids, failed_ids)
126+
127+
Args:
128+
email_ids: List of email IDs to mark.
129+
mailbox: The mailbox containing the emails (default: "INBOX").
130+
read: True to mark as read, False to mark as unread.
131+
"""
132+
133+
@abc.abstractmethod
134+
async def move_emails(
135+
self, email_ids: list[str], destination_mailbox: str, source_mailbox: str = "INBOX"
136+
) -> tuple[list[str], list[str]]:
137+
"""
138+
Move emails to another mailbox. Returns (moved_ids, failed_ids)
139+
140+
Args:
141+
email_ids: List of email IDs to move.
142+
destination_mailbox: Target mailbox name (e.g., "Archive", "Trash").
143+
source_mailbox: Source mailbox (default: "INBOX").
144+
"""
145+
88146
@abc.abstractmethod
89147
async def download_attachment(
90148
self,

0 commit comments

Comments
 (0)