Files
mcp-nextcloud/nextcloud_mcp_server/server/mail.py
T
Chris CoutinhoandClaude Opus 4.8 10e768e414 fix(mail): guard empty message payload in processor (PR #935 round-6)
- processor.py: raise on an empty mail_message payload (OCS data=null with a
  <400 meta) so the task dead-letters instead of indexing a near-empty
  placeholder — mirrors the nc_mail_get_message tool guard. (the round-6
  approve-gating item)
- server/mail.py: clamp limit once in nc_mail_list_messages and base has_more on
  the effective (post-clamp) limit, so a caller passing limit<=0 doesn't get a
  misleading count.
- server/mail.py: note in nc_mail_get_message that attachments with id=null are
  inline body parts and can't be fetched via nc_mail_get_attachment.
- tests: add the first-time-missing incremental scanner case (enters the grace
  period, nothing queued/deleted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:35:49 +02:00

271 lines
10 KiB
Python

"""MCP tools for Nextcloud Mail app (read-only)."""
import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.mail import (
GetAttachmentResponse,
GetMessageResponse,
ListAccountsResponse,
ListMailboxesResponse,
ListMessagesResponse,
MailAccount,
MailMailbox,
MailMessage,
MailMessageSummary,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
# Hard cap on inlined attachment content. The Mail OCS API returns the full
# attachment body in the JSON response, which then has to fit in the host LLM's
# context window; replace anything larger with a sentinel so a 20 MB design file
# can't blow up the MCP response. Callers can still see the real size via the
# message's attachment list.
MAX_ATTACHMENT_CONTENT_BYTES = 5 * 1024 * 1024
def _cap_attachment_content(content: str | None) -> str | None:
"""Replace oversized attachment content with a size sentinel.
Measures UTF-8 byte length (what actually lands in the MCP response/LLM
context), not character count. Non-string content is returned unchanged.
"""
if not isinstance(content, str):
return content
content_bytes = len(content.encode("utf-8"))
if content_bytes > MAX_ATTACHMENT_CONTENT_BYTES:
return (
f"[attachment too large to inline: {content_bytes} bytes "
f"(> {MAX_ATTACHMENT_CONTENT_BYTES})]"
)
return content
def configure_mail_tools(mcp: FastMCP):
"""Configure Mail app MCP tools (read-only)."""
@mcp.tool(
title="List Mail Accounts",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("mail.read")
@instrument_tool
async def nc_mail_list_accounts(ctx: Context) -> ListAccountsResponse:
"""List the user's configured mail accounts (requires mail.read scope)."""
client = await get_client(ctx)
try:
accounts_data = await client.mail.list_accounts()
accounts = [MailAccount(**a) for a in accounts_data]
return ListAccountsResponse(results=accounts, total_count=len(accounts))
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing accounts: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list accounts: {e.response.status_code}",
)
)
@mcp.tool(
title="List Mail Mailboxes",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("mail.read")
@instrument_tool
async def nc_mail_list_mailboxes(
account_id: int, ctx: Context
) -> ListMailboxesResponse:
"""List the mailboxes (folders) of a mail account (requires mail.read scope).
Args:
account_id: Account ID (from nc_mail_list_accounts)
Returns:
ListMailboxesResponse with mailboxes. Use a mailbox's ``database_id``
with nc_mail_list_messages.
"""
client = await get_client(ctx)
try:
mailboxes_data = await client.mail.get_mailboxes(account_id)
mailboxes = [MailMailbox(**m) for m in mailboxes_data]
return ListMailboxesResponse(results=mailboxes, total_count=len(mailboxes))
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing mailboxes: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list mailboxes: {e.response.status_code}",
)
)
@mcp.tool(
title="List Mail Messages",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("mail.read")
@instrument_tool
async def nc_mail_list_messages(
mailbox_id: int,
ctx: Context,
cursor: int | None = None,
search_filter: str | None = None,
limit: int = 20,
) -> ListMessagesResponse:
"""List message envelopes in a mailbox, newest first (requires mail.read scope).
Reads cached envelope metadata (fast); does not fetch bodies. Use
nc_mail_get_message to fetch a full body.
Args:
mailbox_id: Numeric mailbox id (``database_id`` from nc_mail_list_mailboxes)
cursor: Pagination cursor from a prior page
search_filter: Optional search/filter query
limit: Max messages to return (1-100, default 20)
Returns:
ListMessagesResponse with message summaries. ``has_more`` is a
heuristic (true when exactly ``limit`` messages were returned), so it
can be a false positive when a mailbox holds exactly ``limit``
messages; page with ``cursor`` and stop on an empty result.
"""
client = await get_client(ctx)
# Clamp to the same window the client/OCS API enforce so the has_more
# heuristic compares against the limit actually applied (a caller passing
# limit<=0 otherwise gets a misleading count).
effective_limit = min(max(1, limit), 100)
try:
messages_data = await client.mail.list_messages(
mailbox_id,
cursor=cursor,
search_filter=search_filter,
limit=effective_limit,
)
messages = [MailMessageSummary(**m) for m in messages_data]
return ListMessagesResponse(
results=messages,
total_count=len(messages),
has_more=len(messages) == effective_limit,
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing messages: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list messages: {e.response.status_code}",
)
)
@mcp.tool(
title="Get Mail Message",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("mail.read")
@instrument_tool
async def nc_mail_get_message(message_id: int, ctx: Context) -> GetMessageResponse:
"""Get a single mail message with its full body (requires mail.read scope).
The Mail app fetches the body from IMAP server-side.
Args:
message_id: Numeric message id (``database_id`` from nc_mail_list_messages)
Returns:
GetMessageResponse with the full message including body and attachments.
Attachments with ``id: null`` are inline body parts and cannot be
fetched via nc_mail_get_attachment (which requires a string id).
"""
client = await get_client(ctx)
try:
message_data = await client.mail.get_message(message_id)
# An empty payload (OCS data=null with a 200 meta) would make
# MailMessage(**{}) raise an uncaught ValidationError; treat it as
# not-found instead.
if not message_data:
raise McpError(
ErrorData(code=-1, message=f"Message {message_id} not found")
)
message = MailMessage(**message_data)
return GetMessageResponse(message=message)
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error getting message {message_id}: {str(e)}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Message {message_id} not found")
)
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get message {message_id}: "
f"{e.response.status_code}",
)
)
@mcp.tool(
title="Get Mail Attachment",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("mail.read")
@instrument_tool
async def nc_mail_get_attachment(
message_id: int, attachment_id: str, ctx: Context
) -> GetAttachmentResponse:
"""Get a single mail attachment's metadata and content (requires mail.read scope).
Args:
message_id: Numeric message id
attachment_id: Attachment id (a string, from the message's attachments)
Returns:
GetAttachmentResponse with name, mime, size, and content. ``content``
is the attachment body as returned by the Mail OCS API; large
attachments produce a correspondingly large response, so prefer the
``size`` from the message's attachment list before fetching.
"""
client = await get_client(ctx)
try:
data = await client.mail.get_attachment(message_id, attachment_id)
return GetAttachmentResponse(
name=data.get("name"),
mime=data.get("mime"),
size=data.get("size"),
content=_cap_attachment_content(data.get("content")),
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting attachment: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message="Attachment not found"))
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get attachment: {e.response.status_code}",
)
)