feat(mail): read and index Nextcloud Mail via the Mail OCS API
Add read-only support for the Nextcloud Mail app, plus semantic indexing of mail messages. The MCP server never speaks IMAP/POP3 itself: it calls the Mail app's CSRF-free OCS API (/ocs/v2.php/apps/mail/api/...) with the existing Basic-Auth app-password flow and an OCS-APIRequest header, and the Mail app handles IMAP server-side. - client/mail.py: MailClient (accounts, mailboxes, messages, message, attachment), OCS-envelope aware. - models/mail.py: Pydantic models with the API's camelCase aliases. - server/mail.py: 5 read-only MCP tools (mail.read scope), registered in AVAILABLE_APPS. - Vector pipeline: new "mail_message" doc_type wired into scanner (scan_mail_messages, newest-N per mailbox), processor (body -> markdown embedding), per-id verifier, and context expansion. - Tests: client API, model round-trips, verifier behavior; consent-backstop test now derives its allowed set from INDEXED_DOC_TYPES. - README + semantic-search docstrings updated. Requires Mail 5.x / Nextcloud 32+ and a mail account configured in the Mail app. Follow-up: astrolabe must advertise "mail_message" in its enabled_doc_types capability for search under admin doc_type restriction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a9d36a8aee
commit
3074622455
@@ -7,6 +7,7 @@ from .collectives import configure_collectives_tools
|
||||
from .contacts import configure_contacts_tools
|
||||
from .cookbook import configure_cookbook_tools
|
||||
from .deck import configure_deck_tools
|
||||
from .mail import configure_mail_tools
|
||||
from .news import configure_news_tools
|
||||
from .notes import configure_notes_tools
|
||||
from .semantic import configure_semantic_tools
|
||||
@@ -30,6 +31,7 @@ AVAILABLE_APPS: dict[str, Callable[[FastMCP], None]] = {
|
||||
"cookbook": configure_cookbook_tools,
|
||||
"deck": configure_deck_tools,
|
||||
"news": configure_news_tools,
|
||||
"mail": configure_mail_tools,
|
||||
"talk": configure_talk_tools,
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ __all__ = [
|
||||
"configure_contacts_tools",
|
||||
"configure_cookbook_tools",
|
||||
"configure_deck_tools",
|
||||
"configure_mail_tools",
|
||||
"configure_news_tools",
|
||||
"configure_notes_tools",
|
||||
"configure_semantic_tools",
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""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__)
|
||||
|
||||
|
||||
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,
|
||||
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
|
||||
filter: Optional search/filter query
|
||||
limit: Max messages to return (1-100, default 20)
|
||||
|
||||
Returns:
|
||||
ListMessagesResponse with message summaries.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
messages_data = await client.mail.list_messages(
|
||||
mailbox_id, cursor=cursor, filter=filter, limit=limit
|
||||
)
|
||||
messages = [MailMessageSummary(**m) for m in messages_data]
|
||||
return ListMessagesResponse(
|
||||
results=messages,
|
||||
total_count=len(messages),
|
||||
has_more=len(messages) == limit and limit > 0,
|
||||
)
|
||||
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.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
message_data = await client.mail.get_message(message_id)
|
||||
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.
|
||||
"""
|
||||
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=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}",
|
||||
)
|
||||
)
|
||||
@@ -214,12 +214,12 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
understanding and keyword precision.
|
||||
|
||||
Requires VECTOR_SYNC_ENABLED=true. Supports indexing of notes, files,
|
||||
news items, and deck cards.
|
||||
news items, deck cards, and mail messages.
|
||||
|
||||
Args:
|
||||
query: Natural language or keyword search query
|
||||
limit: Maximum number of results to return (default: 10)
|
||||
doc_types: Document types to search (e.g., ["note", "file", "deck_card", "news_item"]). None = search all indexed types (default)
|
||||
doc_types: Document types to search (e.g., ["note", "file", "deck_card", "news_item", "mail_message"]). None = search all indexed types (default)
|
||||
score_threshold: Minimum fusion score (0-1, default: 0.0)
|
||||
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
|
||||
RRF: Good general-purpose fusion using reciprocal ranks
|
||||
|
||||
Reference in New Issue
Block a user