Skip to content

Commit 5bf7a02

Browse files
committed
feat: add mailbox management tools (list, create, move)
Add three new MCP tools for mailbox/folder management: - list_mailboxes: List all available mailboxes/folders - create_mailbox: Create a new mailbox/folder - move_emails: Move emails between mailboxes Implementation details: - Added abstract methods to EmailHandler interface - Implemented methods in EmailClient and ClassicEmailHandler - Uses COPY + DELETE + EXPUNGE for move (IMAP compatibility) - Proper mailbox name quoting via _quote_mailbox() for RFC 3501
1 parent de08972 commit 5bf7a02

File tree

3 files changed

+214
-0
lines changed

3 files changed

+214
-0
lines changed

mcp_email_server/app.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,48 @@ async def download_attachment(
219219

220220
handler = dispatch_handler(account_name)
221221
return await handler.download_attachment(email_id, attachment_name, save_path, mailbox)
222+
223+
224+
@mcp.tool(description="List all available mailboxes/folders for an email account.")
225+
async def list_mailboxes(
226+
account_name: Annotated[str, Field(description="The name of the email account.")],
227+
) -> list[str]:
228+
handler = dispatch_handler(account_name)
229+
return await handler.list_mailboxes()
230+
231+
232+
@mcp.tool(
233+
description="Move one or more emails to another mailbox/folder. Use list_emails_metadata first to get email_ids and list_mailboxes to see available folders."
234+
)
235+
async def move_emails(
236+
account_name: Annotated[str, Field(description="The name of the email account.")],
237+
email_ids: Annotated[
238+
list[str],
239+
Field(description="List of email_id to move (obtained from list_emails_metadata)."),
240+
],
241+
target_mailbox: Annotated[str, Field(description="The target mailbox/folder to move emails to.")],
242+
source_mailbox: Annotated[str, Field(default="INBOX", description="The source mailbox to move emails from.")] = "INBOX",
243+
) -> str:
244+
handler = dispatch_handler(account_name)
245+
moved_ids, failed_ids = await handler.move_emails(email_ids, target_mailbox, source_mailbox)
246+
247+
result = f"Successfully moved {len(moved_ids)} email(s) to '{target_mailbox}'"
248+
if failed_ids:
249+
result += f", failed to move {len(failed_ids)} email(s): {', '.join(failed_ids)}"
250+
return result
251+
252+
253+
@mcp.tool(
254+
description="Create a new mailbox/folder. Use this to organize emails with custom folders."
255+
)
256+
async def create_mailbox(
257+
account_name: Annotated[str, Field(description="The name of the email account.")],
258+
mailbox_name: Annotated[str, Field(description="The name of the mailbox/folder to create.")],
259+
) -> str:
260+
handler = dispatch_handler(account_name)
261+
success = await handler.create_mailbox(mailbox_name)
262+
263+
if success:
264+
return f"Successfully created mailbox '{mailbox_name}'"
265+
else:
266+
return f"Failed to create mailbox '{mailbox_name}'"

mcp_email_server/emails/__init__.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,40 @@ async def download_attachment(
9292
Returns:
9393
AttachmentDownloadResponse with download result information.
9494
"""
95+
96+
@abc.abstractmethod
97+
async def list_mailboxes(self) -> list[str]:
98+
"""
99+
List all available mailboxes/folders.
100+
101+
Returns:
102+
List of mailbox names.
103+
"""
104+
105+
@abc.abstractmethod
106+
async def move_emails(
107+
self, email_ids: list[str], target_mailbox: str, source_mailbox: str = "INBOX"
108+
) -> tuple[list[str], list[str]]:
109+
"""
110+
Move emails to another mailbox.
111+
112+
Args:
113+
email_ids: List of email UIDs to move.
114+
target_mailbox: Target mailbox name.
115+
source_mailbox: Source mailbox name (default: "INBOX").
116+
117+
Returns:
118+
Tuple of (moved_ids, failed_ids).
119+
"""
120+
121+
@abc.abstractmethod
122+
async def create_mailbox(self, mailbox_name: str) -> bool:
123+
"""
124+
Create a new mailbox/folder.
125+
126+
Args:
127+
mailbox_name: The name of the mailbox to create.
128+
129+
Returns:
130+
True if successfully created, False otherwise.
131+
"""

mcp_email_server/emails/classic.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,124 @@ async def delete_emails(self, email_ids: list[str], mailbox: str = "INBOX") -> t
912912

913913
return deleted_ids, failed_ids
914914

915+
async def list_mailboxes(self) -> list[str]:
916+
"""List all available mailboxes/folders."""
917+
imap = self.imap_class(self.email_server.host, self.email_server.port)
918+
mailboxes = []
919+
920+
try:
921+
await imap._client_task
922+
await imap.wait_hello_from_server()
923+
await imap.login(self.email_server.user_name, self.email_server.password)
924+
await _send_imap_id(imap)
925+
926+
# List all folders
927+
_, folders = await imap.list('""', "*")
928+
929+
for folder in folders:
930+
folder_str = folder.decode("utf-8") if isinstance(folder, bytes) else str(folder)
931+
# IMAP LIST response format: (flags) "delimiter" "name"
932+
# Example: (\HasNoChildren) "/" "INBOX"
933+
parts = folder_str.split('"')
934+
if len(parts) >= 3:
935+
folder_name = parts[-2] # The folder name is the second-to-last quoted part
936+
mailboxes.append(folder_name)
937+
938+
finally:
939+
try:
940+
await imap.logout()
941+
except Exception as e:
942+
logger.info(f"Error during logout: {e}")
943+
944+
return mailboxes
945+
946+
async def create_mailbox(self, mailbox_name: str) -> bool:
947+
"""Create a new mailbox/folder.
948+
949+
Args:
950+
mailbox_name: The name of the mailbox to create.
951+
952+
Returns:
953+
True if successfully created, False otherwise.
954+
"""
955+
imap = self.imap_class(self.email_server.host, self.email_server.port)
956+
957+
try:
958+
await imap._client_task
959+
await imap.wait_hello_from_server()
960+
await imap.login(self.email_server.user_name, self.email_server.password)
961+
await _send_imap_id(imap)
962+
963+
# Create the mailbox
964+
result = await imap.create(_quote_mailbox(mailbox_name))
965+
status = result[0] if isinstance(result, tuple) else result
966+
967+
if str(status).upper() == "OK":
968+
logger.info(f"Created mailbox: '{mailbox_name}'")
969+
return True
970+
else:
971+
logger.error(f"Failed to create mailbox '{mailbox_name}': {result}")
972+
return False
973+
974+
except Exception as e:
975+
logger.error(f"Error creating mailbox '{mailbox_name}': {e}")
976+
return False
977+
finally:
978+
try:
979+
await imap.logout()
980+
except Exception as e:
981+
logger.info(f"Error during logout: {e}")
982+
983+
async def move_emails(
984+
self, email_ids: list[str], target_mailbox: str, source_mailbox: str = "INBOX"
985+
) -> tuple[list[str], list[str]]:
986+
"""Move emails to another mailbox. Returns (moved_ids, failed_ids).
987+
988+
IMAP doesn't have a native MOVE command in older versions, so we use:
989+
1. COPY to target mailbox
990+
2. STORE \\Deleted flag
991+
3. EXPUNGE
992+
"""
993+
imap = self.imap_class(self.email_server.host, self.email_server.port)
994+
moved_ids = []
995+
failed_ids = []
996+
997+
try:
998+
await imap._client_task
999+
await imap.wait_hello_from_server()
1000+
await imap.login(self.email_server.user_name, self.email_server.password)
1001+
await _send_imap_id(imap)
1002+
await imap.select(_quote_mailbox(source_mailbox))
1003+
1004+
for email_id in email_ids:
1005+
try:
1006+
# Copy to target mailbox
1007+
result = await imap.uid("copy", email_id, _quote_mailbox(target_mailbox))
1008+
status = result[0] if isinstance(result, tuple) else result
1009+
if str(status).upper() != "OK":
1010+
logger.error(f"Failed to copy email {email_id} to {target_mailbox}: {result}")
1011+
failed_ids.append(email_id)
1012+
continue
1013+
1014+
# Mark as deleted in source
1015+
await imap.uid("store", email_id, "+FLAGS", r"(\Deleted)")
1016+
moved_ids.append(email_id)
1017+
except Exception as e:
1018+
logger.error(f"Failed to move email {email_id}: {e}")
1019+
failed_ids.append(email_id)
1020+
1021+
# Expunge deleted messages
1022+
if moved_ids:
1023+
await imap.expunge()
1024+
1025+
finally:
1026+
try:
1027+
await imap.logout()
1028+
except Exception as e:
1029+
logger.info(f"Error during logout: {e}")
1030+
1031+
return moved_ids, failed_ids
1032+
9151033

9161034
class ClassicEmailHandler(EmailHandler):
9171035
def __init__(self, email_settings: EmailSettings):
@@ -1067,3 +1185,17 @@ async def download_attachment(
10671185
size=result["size"],
10681186
saved_path=result["saved_path"],
10691187
)
1188+
1189+
async def list_mailboxes(self) -> list[str]:
1190+
"""List all available mailboxes/folders."""
1191+
return await self.incoming_client.list_mailboxes()
1192+
1193+
async def move_emails(
1194+
self, email_ids: list[str], target_mailbox: str, source_mailbox: str = "INBOX"
1195+
) -> tuple[list[str], list[str]]:
1196+
"""Move emails to another mailbox. Returns (moved_ids, failed_ids)."""
1197+
return await self.incoming_client.move_emails(email_ids, target_mailbox, source_mailbox)
1198+
1199+
async def create_mailbox(self, mailbox_name: str) -> bool:
1200+
"""Create a new mailbox/folder."""
1201+
return await self.incoming_client.create_mailbox(mailbox_name)

0 commit comments

Comments
 (0)