From 3074622455f0214aae779fd0a6cbf3128e4bbf96 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 20 Jun 2026 11:53:47 +0200 Subject: [PATCH] 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) --- README.md | 5 +- nextcloud_mcp_server/client/__init__.py | 2 + nextcloud_mcp_server/client/mail.py | 155 ++++++++++ nextcloud_mcp_server/models/auth.py | 1 + nextcloud_mcp_server/models/mail.py | 183 ++++++++++++ nextcloud_mcp_server/search/algorithms.py | 5 + nextcloud_mcp_server/search/context.py | 42 +++ nextcloud_mcp_server/search/verification.py | 57 ++++ nextcloud_mcp_server/server/__init__.py | 3 + nextcloud_mcp_server/server/mail.py | 224 ++++++++++++++ nextcloud_mcp_server/server/semantic.py | 4 +- nextcloud_mcp_server/vector/processor.py | 54 ++++ nextcloud_mcp_server/vector/scanner.py | 274 +++++++++++++++++- tests/client/mail/__init__.py | 0 tests/client/mail/test_mail_api.py | 206 +++++++++++++ tests/unit/search/test_verification.py | 92 ++++++ tests/unit/test_mail_models.py | 109 +++++++ .../vector/test_scanner_consent_backstop.py | 4 +- 18 files changed, 1413 insertions(+), 7 deletions(-) create mode 100644 nextcloud_mcp_server/client/mail.py create mode 100644 nextcloud_mcp_server/models/mail.py create mode 100644 nextcloud_mcp_server/server/mail.py create mode 100644 tests/client/mail/__init__.py create mode 100644 tests/client/mail/test_mail_api.py create mode 100644 tests/unit/test_mail_models.py diff --git a/README.md b/README.md index 8a99a60b..3b05e326 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ For Kubernetes, see [cbcoutinho/helm-charts](https://github.com/cbcoutinho/helm- - **110+ MCP Tools** - Comprehensive API coverage across 10 Nextcloud apps - **MCP Resources** - Structured data URIs for browsing Nextcloud data -- **Semantic Search (Experimental)** - Optional vector-powered search for Notes, Files, News items, and Deck cards (requires Qdrant + Ollama) +- **Semantic Search (Experimental)** - Optional vector-powered search for Notes, Files, News items, Deck cards, and Mail messages (requires Qdrant + Ollama) - **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications - **Flexible Deployment** - Docker, Kubernetes ([Helm chart](https://github.com/cbcoutinho/helm-charts)), VM, or local installation - **Production-Ready Auth** - Basic Auth with app passwords; multi-user via Login Flow v2 — MCP clients authenticate via OAuth, the server handles Nextcloud app passwords transparently @@ -90,9 +90,10 @@ For Kubernetes, see [cbcoutinho/helm-charts](https://github.com/cbcoutinho/helm- | **Tables** | 5 | Row operations on Nextcloud Tables | | **Sharing** | 10+ | Create and manage shares | | **News** | 8 | Feeds, folders, items, feed health monitoring | +| **Mail** | 5 | Read-only: accounts, mailboxes, messages, attachments (via Mail app's OCS API) | | **Collectives** | 16 | Full CRUD on collectives, pages, and tags | | **Talk (spreed)** | 6 | List conversations, read/post messages, mark as read, list participants | -| **Semantic Search** | 2+ | Vector search for Notes, Files, News items, and Deck cards (experimental, opt-in, requires infrastructure) | +| **Semantic Search** | 2+ | Vector search for Notes, Files, News items, Deck cards, and Mail messages (experimental, opt-in, requires infrastructure) | Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request! diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index 6891e47d..f8386fa3 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -20,6 +20,7 @@ from .contacts import ContactsClient from .cookbook import CookbookClient from .deck import DeckClient from .groups import GroupsClient +from .mail import MailClient from .news import NewsClient from .notes import NotesClient from .sharing import SharingClient @@ -144,6 +145,7 @@ class NextcloudClient: self.collectives = CollectivesClient(self._client, username) self.deck = DeckClient(self._client, username) self.news = NewsClient(self._client, username) + self.mail = MailClient(self._client, username) self.talk = TalkClient(self._client, username) self.users = UsersClient(self._client, username) self.groups = GroupsClient(self._client, username) diff --git a/nextcloud_mcp_server/client/mail.py b/nextcloud_mcp_server/client/mail.py new file mode 100644 index 00000000..75e8a331 --- /dev/null +++ b/nextcloud_mcp_server/client/mail.py @@ -0,0 +1,155 @@ +"""Client for Nextcloud Mail app operations (read-only). + +Talks to the Mail app's OCS API under ``/ocs/v2.php/apps/mail/api/...``. The +Mail app's *server* handles the IMAP connection on the user's behalf, so this +client only ever speaks HTTP to Nextcloud — it never connects to IMAP/POP3 +itself. The read endpoints are ``#[NoCSRFRequired]`` + ``#[NoAdminRequired]``, +so they are reachable with the same Basic-Auth app-password flow the other app +clients use, provided the ``OCS-APIRequest`` header is sent. + +Prerequisites: the mail account must already be configured inside the Nextcloud +Mail app (so the server has IMAP credentials), and the Mail app must expose the +OCS API controllers (Mail 5.x / Nextcloud 32+). +""" + +import logging +from typing import Any + +from .base import BaseNextcloudClient + +logger = logging.getLogger(__name__) + + +class MailClient(BaseNextcloudClient): + """Read-only client for Nextcloud Mail app operations.""" + + app_name = "mail" + API_BASE = "/ocs/v2.php/apps/mail/api" + + # OCS endpoints require this header; without it Nextcloud rejects the + # request (or redirects to a login page). ``format=json`` forces a JSON + # envelope rather than XML. + _OCS_HEADERS = {"OCS-APIRequest": "true", "Accept": "application/json"} + + async def _ocs_get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: + """GET an OCS endpoint and unwrap the ``ocs.data`` payload. + + Args: + path: Path under ``API_BASE`` (e.g. ``/account/list``) + params: Optional query params (``format=json`` is added automatically) + + Returns: + The ``data`` payload (a list, dict, or string depending on endpoint) + """ + query: dict[str, Any] = {"format": "json"} + if params: + query.update(params) + response = await self._make_request( + "GET", + f"{self.API_BASE}{path}", + params=query, + headers=self._OCS_HEADERS, + ) + body = response.json() + # Standard OCS envelope: {"ocs": {"meta": {...}, "data": }} + return body.get("ocs", {}).get("data") + + # --- Accounts --- + + async def list_accounts(self) -> list[dict[str, Any]]: + """List the user's configured mail accounts. + + Returns: + List of account objects (keys: id, email, isDelegated, aliases) + """ + data = await self._ocs_get("/account/list") + return data or [] + + # --- Mailboxes --- + + async def get_mailboxes(self, account_id: int) -> list[dict[str, Any]]: + """List the mailboxes (folders) of an account. + + Args: + account_id: Account ID (the ``id`` from :meth:`list_accounts`) + + Returns: + List of mailbox objects. Note ``databaseId`` is the numeric mailbox + id needed by :meth:`list_messages` (``id`` is a base64 string). + """ + data = await self._ocs_get("/mailboxes", params={"accountId": account_id}) + return data or [] + + # --- Messages --- + + async def list_messages( + self, + mailbox_id: int, + *, + cursor: int | None = None, + filter: str | None = None, + limit: int = 20, + view: str | None = None, + ) -> list[dict[str, Any]]: + """List message envelopes in a mailbox (newest first). + + Reads DB-cached envelope metadata, so this is fast and does not hit + IMAP per request. + + Args: + mailbox_id: Numeric mailbox id (``databaseId`` from get_mailboxes) + cursor: Pagination cursor (timestamp/id from a prior page) + filter: Optional search/filter query + limit: Max messages to return. Clamped server-side to 1..100; a + missing limit collapses to 1 server-side, so always pass one. + view: ``"singleton"`` or ``"threaded"`` (default threaded) + + Returns: + List of message summary objects (keys include databaseId, subject, + from, to, dateInt, flags, previewText, mailboxId). + """ + params: dict[str, Any] = {"limit": limit} + if cursor is not None: + params["cursor"] = cursor + if filter is not None: + params["filter"] = filter + if view is not None: + params["view"] = view + data = await self._ocs_get(f"/mailboxes/{mailbox_id}/messages", params=params) + return data or [] + + async def get_message(self, message_id: int) -> dict[str, Any]: + """Get a single message with its full body. + + The Mail app fetches the body from IMAP server-side and returns it as a + single ``body`` field (sanitized HTML when ``hasHtmlBody`` is true, + otherwise plain text). ``body`` may be absent on partial (206) responses + when S/MIME decryption fails. + + Args: + message_id: Numeric message id (``databaseId`` from list_messages) + + Returns: + Full message object. + """ + data = await self._ocs_get(f"/message/{message_id}") + return data or {} + + async def get_attachment( + self, message_id: int, attachment_id: str + ) -> dict[str, Any]: + """Get a single attachment's metadata and content. + + The Mail OCS API returns the attachment as a JSON object (not a binary + download): keys ``name``, ``mime``, ``size``, ``content``. + + Args: + message_id: Numeric message id + attachment_id: Attachment id (a string; from the message's + ``attachments`` array) + + Returns: + Attachment object with name, mime, size, content. + """ + data = await self._ocs_get(f"/message/{message_id}/attachment/{attachment_id}") + return data or {} diff --git a/nextcloud_mcp_server/models/auth.py b/nextcloud_mcp_server/models/auth.py index 45a27f68..3b93ffce 100644 --- a/nextcloud_mcp_server/models/auth.py +++ b/nextcloud_mcp_server/models/auth.py @@ -74,6 +74,7 @@ ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset( "sharing.write", "news.read", "news.write", + "mail.read", "collectives.read", "collectives.write", } diff --git a/nextcloud_mcp_server/models/mail.py b/nextcloud_mcp_server/models/mail.py new file mode 100644 index 00000000..b084cbdf --- /dev/null +++ b/nextcloud_mcp_server/models/mail.py @@ -0,0 +1,183 @@ +"""Pydantic models for Nextcloud Mail app responses (read-only).""" + +from typing import List + +from pydantic import BaseModel, ConfigDict, Field + +from .base import BaseResponse + + +class MailAddress(BaseModel): + """An email address with an optional display label.""" + + model_config = ConfigDict(populate_by_name=True) + + label: str | None = Field(None, description="Display name") + email: str | None = Field(None, description="Email address") + + +class MailAccount(BaseModel): + """A configured mail account (from the account/list endpoint).""" + + model_config = ConfigDict(populate_by_name=True) + + id: int = Field(description="Account ID") + email: str = Field(description="Account email address") + is_delegated: bool = Field( + False, alias="isDelegated", description="Whether this is a delegated account" + ) + + +class MailMailbox(BaseModel): + """A mailbox (folder) within an account.""" + + model_config = ConfigDict(populate_by_name=True) + + # ``databaseId`` is the numeric id used by list_messages; ``id`` is a + # base64-encoded mailbox name (a string), so we expose the numeric one. + database_id: int = Field(alias="databaseId", description="Numeric mailbox ID") + name: str = Field(description="IMAP mailbox name (e.g. INBOX, Sent)") + display_name: str | None = Field( + None, alias="displayName", description="Human-readable mailbox name" + ) + account_id: int = Field(alias="accountId", description="Parent account ID") + special_use: List[str] = Field( + default_factory=list, + alias="specialUse", + description="Special-use roles (e.g. inbox, sent, trash)", + ) + unread: int = Field(0, description="Number of unread messages") + + +class MailMessageFlags(BaseModel): + """IMAP flags on a message.""" + + model_config = ConfigDict(populate_by_name=True) + + seen: bool = False + flagged: bool = False + answered: bool = False + deleted: bool = False + draft: bool = False + forwarded: bool = False + has_attachments: bool = Field(False, alias="hasAttachments") + important: bool = False + + +class MailAttachment(BaseModel): + """Metadata for a message attachment.""" + + model_config = ConfigDict(populate_by_name=True) + + # Attachment id is a string and may be null for inline/body-part messages. + id: str | None = Field(None, description="Attachment ID") + file_name: str | None = Field( + None, alias="fileName", description="Attachment file name" + ) + mime: str | None = Field(None, description="MIME type") + size: int | None = Field(None, description="Size in bytes") + cid: str | None = Field(None, description="Content-ID (for inline attachments)") + disposition: str | None = Field( + None, description="Content disposition (attachment/inline)" + ) + + +class MailMessageSummary(BaseModel): + """Lightweight message envelope for mailbox listings.""" + + model_config = ConfigDict(populate_by_name=True) + + # ``databaseId`` is the numeric id passed to get_message. + database_id: int = Field(alias="databaseId", description="Numeric message ID") + uid: int | None = Field(None, description="IMAP UID") + subject: str | None = Field(None, description="Message subject") + date_int: int | None = Field( + None, alias="dateInt", description="Sent date as a Unix timestamp (seconds)" + ) + from_: List[MailAddress] = Field( + default_factory=list, alias="from", description="Sender addresses" + ) + to: List[MailAddress] = Field( + default_factory=list, description="Recipient addresses" + ) + mailbox_id: int | None = Field( + None, alias="mailboxId", description="Parent mailbox ID" + ) + preview_text: str | None = Field( + None, alias="previewText", description="Short preview snippet" + ) + flags: MailMessageFlags | None = Field(None, description="IMAP flags") + + +class MailMessage(BaseModel): + """Full message with body (from the message/{id} endpoint).""" + + model_config = ConfigDict(populate_by_name=True) + + id: int = Field(description="Numeric message ID") + uid: int | None = Field(None, description="IMAP UID") + message_id: str | None = Field( + None, alias="messageId", description="RFC Message-ID header" + ) + subject: str | None = Field(None, description="Message subject") + date_int: int | None = Field( + None, alias="dateInt", description="Sent date as a Unix timestamp (seconds)" + ) + from_: List[MailAddress] = Field( + default_factory=list, alias="from", description="Sender addresses" + ) + to: List[MailAddress] = Field( + default_factory=list, description="Recipient addresses" + ) + cc: List[MailAddress] = Field(default_factory=list, description="CC addresses") + bcc: List[MailAddress] = Field(default_factory=list, description="BCC addresses") + has_html_body: bool = Field( + False, alias="hasHtmlBody", description="Whether the body is HTML" + ) + body: str | None = Field( + None, + description="Rendered body (sanitized HTML if hasHtmlBody, else plain text)", + ) + attachments: List[MailAttachment] = Field( + default_factory=list, description="Message attachments" + ) + + +# --- Response Models --- + + +class ListAccountsResponse(BaseResponse): + """Response model for listing mail accounts.""" + + results: List[MailAccount] = Field(description="List of mail accounts") + total_count: int = Field(description="Total number of accounts") + + +class ListMailboxesResponse(BaseResponse): + """Response model for listing mailboxes.""" + + results: List[MailMailbox] = Field(description="List of mailboxes") + total_count: int = Field(description="Total number of mailboxes") + + +class ListMessagesResponse(BaseResponse): + """Response model for listing message envelopes.""" + + results: List[MailMessageSummary] = Field(description="List of message summaries") + total_count: int = Field(description="Number of messages returned") + has_more: bool = Field(False, description="Whether more messages may exist") + + +class GetMessageResponse(BaseResponse): + """Response model for getting a single full message.""" + + message: MailMessage = Field(description="Full message details") + + +class GetAttachmentResponse(BaseResponse): + """Response model for getting a single attachment.""" + + name: str | None = Field(None, description="Attachment file name") + mime: str | None = Field(None, description="MIME type") + size: int | None = Field(None, description="Size in bytes") + content: str | None = Field(None, description="Attachment content") diff --git a/nextcloud_mcp_server/search/algorithms.py b/nextcloud_mcp_server/search/algorithms.py index b4990b0c..9a228f01 100644 --- a/nextcloud_mcp_server/search/algorithms.py +++ b/nextcloud_mcp_server/search/algorithms.py @@ -76,6 +76,11 @@ class NextcloudClientProtocol(Protocol): """News client for accessing news item documents.""" ... + @property + def mail(self) -> Any: + """Mail client for accessing mail message documents.""" + ... + # Top-level client helper (not a sub-client) used by verify-on-read to # gate file results on current vector-index tag membership. async def find_files_by_tag( diff --git a/nextcloud_mcp_server/search/context.py b/nextcloud_mcp_server/search/context.py index d8d898fd..8c484044 100644 --- a/nextcloud_mcp_server/search/context.py +++ b/nextcloud_mcp_server/search/context.py @@ -822,6 +822,48 @@ async def _fetch_document_text( if card.description: content_parts.append(card.description) return "\n\n".join(content_parts) + elif doc_type == "mail_message": + # Mail message IDs are positive ASCII integers (MySQL AUTO_INCREMENT). + if not is_valid_nextcloud_doc_id(doc_id): + logger.warning( + "Expected numeric mail_message doc_id, got %r — skipping document fetch", + doc_id, + ) + return None + # Reconstruct full content as indexed by the processor (subject + + # From + To + blank line + body) so chunk offsets align. Keep this in + # sync with the mail_message branch in vector/processor.py. + message = await nc_client.mail.get_message(int(doc_id)) + + def _format_addresses(addrs: list[dict] | None) -> str: + parts = [] + for addr in addrs or []: + label = addr.get("label") + email = addr.get("email") + if label and email and label != email: + parts.append(f"{label} <{email}>") + elif email: + parts.append(email) + elif label: + parts.append(label) + return ", ".join(parts) + + subject = message.get("subject") or "" + from_str = _format_addresses(message.get("from")) + to_str = _format_addresses(message.get("to")) + raw_body = message.get("body") or "" + body_text = ( + html_to_markdown(raw_body) if message.get("hasHtmlBody") else raw_body + ) + + content_parts = [subject] + if from_str: + content_parts.append(f"From: {from_str}") + if to_str: + content_parts.append(f"To: {to_str}") + content_parts.append("") # Blank line + content_parts.append(body_text) + return "\n".join(content_parts) else: logger.warning("Unsupported doc_type for context expansion: %s", doc_type) return None diff --git a/nextcloud_mcp_server/search/verification.py b/nextcloud_mcp_server/search/verification.py index 1192d5fb..9d319021 100644 --- a/nextcloud_mcp_server/search/verification.py +++ b/nextcloud_mcp_server/search/verification.py @@ -488,11 +488,68 @@ async def _verify_news_items( return accessible +async def _verify_mail_messages( + client: NextcloudClientProtocol, + results: list[SearchResult], + semaphore: anyio.Semaphore, +) -> set[str]: + """Verify mail messages per-id via the Mail OCS ``get_message`` endpoint. + + Mirrors ``_verify_notes``: a definitive 403/404 (message deleted, account + removed, or Mail app disabled) drops the result and schedules eviction; + transient errors and non-numeric ids fail open (keep the result). + """ + # safe: cooperative concurrency, no lock needed (see verify_search_results) + accessible: set[str] = set() + + async def check(result: SearchResult) -> None: + doc_id = result.id + try: + message_id_int = int(doc_id) + except (TypeError, ValueError) as e: + logger.warning( + "Non-numeric mail message id %r: %s; keeping result", + doc_id, + e, + ) + accessible.add(doc_id) + return + + async with semaphore: + try: + await client.mail.get_message(message_id_int) + accessible.add(doc_id) + except HTTPStatusError as e: + if _is_definitive_404_or_403(e): + return + logger.warning( + "Transient error verifying mail message %s: %s %s; keeping result", + doc_id, + e.response.status_code, + e, + ) + accessible.add(doc_id) + except Exception as e: + logger.warning( + "Unexpected error verifying mail message %s: %s; keeping result", + doc_id, + e, + ) + accessible.add(doc_id) + + async with anyio.create_task_group() as tg: + for r in results: + tg.start_soon(check, r) + + return accessible + + _VERIFIERS: dict[str, BatchVerifier] = { "note": _verify_notes, "file": _verify_files, "deck_card": _verify_deck_cards, "news_item": _verify_news_items, + "mail_message": _verify_mail_messages, } diff --git a/nextcloud_mcp_server/server/__init__.py b/nextcloud_mcp_server/server/__init__.py index e2f4616b..42428a65 100644 --- a/nextcloud_mcp_server/server/__init__.py +++ b/nextcloud_mcp_server/server/__init__.py @@ -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", diff --git a/nextcloud_mcp_server/server/mail.py b/nextcloud_mcp_server/server/mail.py new file mode 100644 index 00000000..cdda67ca --- /dev/null +++ b/nextcloud_mcp_server/server/mail.py @@ -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}", + ) + ) diff --git a/nextcloud_mcp_server/server/semantic.py b/nextcloud_mcp_server/server/semantic.py index 27f84102..20daa203 100644 --- a/nextcloud_mcp_server/server/semantic.py +++ b/nextcloud_mcp_server/server/semantic.py @@ -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 diff --git a/nextcloud_mcp_server/vector/processor.py b/nextcloud_mcp_server/vector/processor.py index cc421b7e..c63462b0 100644 --- a/nextcloud_mcp_server/vector/processor.py +++ b/nextcloud_mcp_server/vector/processor.py @@ -840,6 +840,60 @@ async def _index_document( file_path = None content_bytes = None content_type = None + elif doc_task.doc_type == "mail_message": + # Fetch the full message via the Mail OCS API. The Mail app handles + # IMAP server-side; we only ever speak HTTP. + message = await nc_client.mail.get_message(int(doc_task.doc_id)) + + def _format_addresses(addrs: list[dict] | None) -> str: + parts = [] + for addr in addrs or []: + label = addr.get("label") + email = addr.get("email") + if label and email and label != email: + parts.append(f"{label} <{email}>") + elif email: + parts.append(email) + elif label: + parts.append(label) + return ", ".join(parts) + + subject = message.get("subject") or "" + from_str = _format_addresses(message.get("from")) + to_str = _format_addresses(message.get("to")) + # Body is sanitized HTML when hasHtmlBody, else plain text. Convert + # HTML to Markdown for better embedding; pass plain text through. + raw_body = message.get("body") or "" + body_text = ( + html_to_markdown(raw_body) if message.get("hasHtmlBody") else raw_body + ) + + content_parts = [subject] + if from_str: + content_parts.append(f"From: {from_str}") + if to_str: + content_parts.append(f"To: {to_str}") + content_parts.append("") # Blank line + content_parts.append(body_text) + content = "\n".join(content_parts) + + title = subject + # Email is immutable; key change-detection on the message id so a + # re-index is a no-op unless the id changes. + etag = str(message.get("id") or doc_task.doc_id) + file_metadata = { + "subject": subject, + "from": from_str, + "to": to_str, + "cc": _format_addresses(message.get("cc")), + "date_int": message.get("dateInt"), + "has_attachments": bool(message.get("attachments")), + "account_id": (doc_task.metadata or {}).get("account_id"), + "mailbox_id": (doc_task.metadata or {}).get("mailbox_id"), + } + file_path = None + content_bytes = None + content_type = None elif doc_task.doc_type == "deck_card": # Fetch card from Deck API # Use metadata from scanner if available (O(1) lookup) diff --git a/nextcloud_mcp_server/vector/scanner.py b/nextcloud_mcp_server/vector/scanner.py index fdf381e7..b9150d9c 100644 --- a/nextcloud_mcp_server/vector/scanner.py +++ b/nextcloud_mcp_server/vector/scanner.py @@ -48,7 +48,7 @@ logger = logging.getLogger(__name__) # same PR that adds a new indexed doc_type, or accept ghost-record exposure for # that type (see ADR-019). INDEXED_DOC_TYPES: frozenset[str] = frozenset( - {"note", "file", "deck_card", "news_item"} + {"note", "file", "deck_card", "news_item", "mail_message"} ) @@ -956,13 +956,36 @@ async def scan_user_documents( user_id, ) + # Scan Mail messages (newest per mailbox) + mail_queued = 0 + if _should_scan("mail", "mail_message", enabled_apps, allowed): + try: + mail_queued = await scan_mail_messages( + user_id=user_id, + send_stream=send_stream, + nc_client=nc_client, + initial_sync=initial_sync, + scan_id=scan_id, + ) + queued += mail_queued + except Exception as e: + logger.warning("Failed to scan mail messages for %s: %s", user_id, e) + else: + logger.debug( + "[SCAN-%s] Mail app not enabled for %s; skipping mail messages", + scan_id, + user_id, + ) + if queued > 0: logger.info( - "Sent %s documents (%s files, %s news items, %s deck cards) for incremental sync: %s", + "Sent %s documents (%s files, %s news items, %s deck cards, " + "%s mail messages) for incremental sync: %s", queued, file_queued, news_queued, deck_queued, + mail_queued, user_id, ) else: @@ -1332,6 +1355,253 @@ async def scan_news_items( return queued +# Newest-N messages indexed per mailbox. The Mail app paginates by recency, so +# this bounds the index to recent mail; older messages age out of the index as +# newer ones arrive (and the deletion-tracking pass below evicts them, the same +# way it handles actually-deleted messages). Raise this if deeper history is +# wanted, at the cost of more embedding work. +MAIL_SCAN_MAX_PER_MAILBOX = 100 + + +async def scan_mail_messages( + user_id: str, + send_stream: TaskProducer, + nc_client: NextcloudClient, + initial_sync: bool, + scan_id: int, +) -> int: + """ + Scan a user's Mail messages and queue changed messages for indexing. + + Enumerates accounts → mailboxes → newest ``MAIL_SCAN_MAX_PER_MAILBOX`` + messages per mailbox. Email is immutable, so a message's ``dateInt`` (sent + timestamp) is used as the change-detection ``modified_at`` — a message is + indexed once and not re-sent. Messages that drop out of the newest-N window + (or are deleted) are evicted via the deletion-tracking pass, keeping the + index bounded to recent mail. + + The MCP server never speaks IMAP: listing reads the Mail app's DB-cached + envelopes, and the body fetch (in the processor) goes through the Mail app's + OCS API, which handles IMAP server-side. + + Args: + user_id: User to scan + send_stream: Stream to send changed documents to processors + nc_client: Authenticated Nextcloud client + initial_sync: If True, send all documents (first-time sync) + scan_id: Scan identifier for logging + + Returns: + Number of messages queued for processing + """ + settings = get_settings() + queued = 0 + + # Get indexed mail message IDs from Qdrant (for deletion tracking) + indexed_message_ids: set[str] = set() + if not initial_sync: + qdrant_client = await get_qdrant_client() + points = await _scroll_all_points( + qdrant_client, + collection_name=settings.get_collection_name(), + scroll_filter=Filter( + must=[ + FieldCondition(key="user_id", match=MatchValue(value=user_id)), + FieldCondition( + key="doc_type", match=MatchValue(value="mail_message") + ), + ] + ), + payload_fields=["doc_id"], + ) + indexed_message_ids = { + str(point.payload["doc_id"]) + for point in points + if point.payload is not None and "doc_id" in point.payload + } + logger.debug( + "Found %s indexed mail messages in Qdrant", len(indexed_message_ids) + ) + + # Enumerate accounts → mailboxes → newest-N messages. + accounts = await nc_client.mail.list_accounts() + nextcloud_message_ids: set[str] = set() + message_count = 0 + + for account in accounts: + account_id = account.get("id") + if account_id is None: + continue + try: + mailboxes = await nc_client.mail.get_mailboxes(account_id) + except Exception as e: + logger.warning( + "[SCAN-%s] Failed to list mailboxes for account %s: %s", + scan_id, + account_id, + e, + ) + continue + + for mailbox in mailboxes: + mailbox_id = mailbox.get("databaseId") + if mailbox_id is None: + continue + try: + messages = await nc_client.mail.list_messages( + mailbox_id, limit=MAIL_SCAN_MAX_PER_MAILBOX + ) + except Exception as e: + logger.warning( + "[SCAN-%s] Failed to list messages for mailbox %s: %s", + scan_id, + mailbox_id, + e, + ) + continue + + if len(messages) >= MAIL_SCAN_MAX_PER_MAILBOX: + logger.debug( + "[SCAN-%s] Mailbox %s hit the newest-%s cap; older messages " + "are not indexed", + scan_id, + mailbox_id, + MAIL_SCAN_MAX_PER_MAILBOX, + ) + + for message in messages: + msg_db_id = message.get("databaseId") + if msg_db_id is None: + continue + doc_id = str(msg_db_id) + nextcloud_message_ids.add(doc_id) + message_count += 1 + + modified_at = message.get("dateInt", 0) or 0 + task_metadata: dict[str, int | str] = { + "account_id": account_id, + "mailbox_id": mailbox_id, + } + + if initial_sync: + await write_placeholder_point( + doc_id=doc_id, + doc_type="mail_message", + user_id=user_id, + modified_at=modified_at, + ) + await send_stream.send( + DocumentTask( + user_id=user_id, + doc_id=doc_id, + doc_type="mail_message", + operation="index", + modified_at=modified_at, + metadata=task_metadata, + ) + ) + queued += 1 + else: + doc_key = (user_id, doc_id) + if doc_key in _potentially_deleted: + logger.debug( + "Mail message %s reappeared, removing from deletion " + "grace period", + doc_id, + ) + del _potentially_deleted[doc_key] + + existing_metadata = await query_document_metadata( + doc_id=doc_id, doc_type="mail_message", user_id=user_id + ) + + needs_indexing = False + if existing_metadata is None: + needs_indexing = True + elif existing_metadata.get("modified_at", 0) < modified_at: + needs_indexing = True + elif existing_metadata.get("is_placeholder", False): + queued_at = existing_metadata.get("queued_at", 0) + placeholder_age = time.time() - queued_at + stale_threshold = settings.vector_sync_scan_interval * 5 + if placeholder_age > stale_threshold: + logger.debug( + "Found stale placeholder for mail message %s " + "(age=%ss), requeuing", + doc_id, + format(placeholder_age, ".1f"), + ) + needs_indexing = True + + if needs_indexing: + await write_placeholder_point( + doc_id=doc_id, + doc_type="mail_message", + user_id=user_id, + modified_at=modified_at, + ) + await send_stream.send( + DocumentTask( + user_id=user_id, + doc_id=doc_id, + doc_type="mail_message", + operation="index", + modified_at=modified_at, + metadata=task_metadata, + ) + ) + queued += 1 + + logger.info( + "[SCAN-%s] Found %s mail messages for %s", + scan_id, + message_count, + user_id, + ) + record_vector_sync_scan(message_count) + + # Check for deleted / aged-out messages (not initial sync) + if not initial_sync: + grace_period = settings.vector_sync_scan_interval * 1.5 + current_time = time.time() + + for doc_id in indexed_message_ids: + if doc_id not in nextcloud_message_ids: + doc_key = (user_id, doc_id) + + if doc_key in _potentially_deleted: + first_missing_time = _potentially_deleted[doc_key] + time_missing = current_time - first_missing_time + + if time_missing >= grace_period: + logger.info( + "Mail message %s missing for %ss (>%ss grace period), " + "sending deletion", + doc_id, + format(time_missing, ".1f"), + format(grace_period, ".1f"), + ) + await send_stream.send( + DocumentTask( + user_id=user_id, + doc_id=doc_id, + doc_type="mail_message", + operation="delete", + modified_at=0, + ) + ) + queued += 1 + del _potentially_deleted[doc_key] + else: + logger.debug( + "Mail message %s missing for first time, starting grace period", + doc_id, + ) + _potentially_deleted[doc_key] = current_time + + return queued + + async def scan_deck_cards( user_id: str, send_stream: TaskProducer, diff --git a/tests/client/mail/__init__.py b/tests/client/mail/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/client/mail/test_mail_api.py b/tests/client/mail/test_mail_api.py new file mode 100644 index 00000000..dec7c929 --- /dev/null +++ b/tests/client/mail/test_mail_api.py @@ -0,0 +1,206 @@ +"""Unit tests for MailClient API methods.""" + +import logging +from typing import Any + +import httpx +import pytest + +from nextcloud_mcp_server.client.mail import MailClient +from tests.client.conftest import create_mock_response + +logger = logging.getLogger(__name__) + +# Mark all tests in this module as unit tests +pytestmark = pytest.mark.unit + + +def _ocs_response(data: Any, status_code: int = 200) -> httpx.Response: + """Wrap a payload in the standard OCS envelope.""" + return create_mock_response( + status_code=status_code, + json_data={ + "ocs": { + "meta": {"status": "ok", "statuscode": status_code, "message": "OK"}, + "data": data, + } + }, + ) + + +async def test_list_accounts_unwraps_ocs_envelope(mocker): + """list_accounts returns the ocs.data payload.""" + mock_response = _ocs_response( + [ + {"id": 1, "email": "alice@example.com", "isDelegated": False}, + {"id": 2, "email": "bob@example.com", "isDelegated": False}, + ] + ) + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + MailClient, "_make_request", return_value=mock_response + ) + + client = MailClient(mock_client, "testuser") + accounts = await client.list_accounts() + + assert len(accounts) == 2 + assert accounts[0]["id"] == 1 + assert accounts[0]["email"] == "alice@example.com" + + # Correct URL, OCS header, and format=json param. + args, kwargs = mock_make_request.call_args + assert args == ("GET", "/ocs/v2.php/apps/mail/api/account/list") + assert kwargs["headers"]["OCS-APIRequest"] == "true" + assert kwargs["params"]["format"] == "json" + + +async def test_get_mailboxes_passes_account_id(mocker): + """get_mailboxes sends accountId and unwraps the list.""" + mock_response = _ocs_response( + [ + { + "databaseId": 10, + "id": "SU5CT1g=", + "name": "INBOX", + "displayName": "INBOX", + "accountId": 1, + "specialUse": ["inbox"], + "unread": 3, + } + ] + ) + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + MailClient, "_make_request", return_value=mock_response + ) + + client = MailClient(mock_client, "testuser") + mailboxes = await client.get_mailboxes(account_id=1) + + assert len(mailboxes) == 1 + assert mailboxes[0]["databaseId"] == 10 + assert mailboxes[0]["specialUse"] == ["inbox"] + + args, kwargs = mock_make_request.call_args + assert args == ("GET", "/ocs/v2.php/apps/mail/api/mailboxes") + assert kwargs["params"]["accountId"] == 1 + + +async def test_list_messages_builds_params(mocker): + """list_messages forwards limit/cursor/filter/view query params.""" + mock_response = _ocs_response( + [ + { + "databaseId": 100, + "subject": "Hello", + "dateInt": 1700000000, + "from": [{"label": "Alice", "email": "alice@example.com"}], + "to": [{"label": "Bob", "email": "bob@example.com"}], + "mailboxId": 10, + } + ] + ) + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + MailClient, "_make_request", return_value=mock_response + ) + + client = MailClient(mock_client, "testuser") + messages = await client.list_messages( + 10, cursor=42, filter="hello", limit=50, view="threaded" + ) + + assert len(messages) == 1 + assert messages[0]["databaseId"] == 100 + + args, kwargs = mock_make_request.call_args + assert args == ("GET", "/ocs/v2.php/apps/mail/api/mailboxes/10/messages") + assert kwargs["params"]["limit"] == 50 + assert kwargs["params"]["cursor"] == 42 + assert kwargs["params"]["filter"] == "hello" + assert kwargs["params"]["view"] == "threaded" + + +async def test_list_messages_omits_optional_params(mocker): + """Optional params are omitted when not supplied; limit always present.""" + mock_response = _ocs_response([]) + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + MailClient, "_make_request", return_value=mock_response + ) + + client = MailClient(mock_client, "testuser") + await client.list_messages(10) + + _, kwargs = mock_make_request.call_args + params = kwargs["params"] + assert params["limit"] == 20 # default + assert "cursor" not in params + assert "filter" not in params + assert "view" not in params + + +async def test_get_message_unwraps_full_message(mocker): + """get_message returns the full message dict.""" + mock_response = _ocs_response( + { + "id": 100, + "subject": "Hello", + "hasHtmlBody": True, + "body": "

Hi there

", + "from": [{"label": "Alice", "email": "alice@example.com"}], + "attachments": [ + { + "id": "1.2", + "fileName": "doc.pdf", + "mime": "application/pdf", + "size": 1024, + } + ], + } + ) + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + MailClient, "_make_request", return_value=mock_response + ) + + client = MailClient(mock_client, "testuser") + message = await client.get_message(100) + + assert message["id"] == 100 + assert message["hasHtmlBody"] is True + assert message["attachments"][0]["fileName"] == "doc.pdf" + + args, _ = mock_make_request.call_args + assert args == ("GET", "/ocs/v2.php/apps/mail/api/message/100") + + +async def test_get_attachment_unwraps_json(mocker): + """get_attachment returns the JSON attachment object (not a binary download).""" + mock_response = _ocs_response( + {"name": "doc.pdf", "mime": "application/pdf", "size": 1024, "content": "abc"} + ) + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + MailClient, "_make_request", return_value=mock_response + ) + + client = MailClient(mock_client, "testuser") + attachment = await client.get_attachment(100, "1.2") + + assert attachment["name"] == "doc.pdf" + assert attachment["content"] == "abc" + + args, _ = mock_make_request.call_args + assert args == ("GET", "/ocs/v2.php/apps/mail/api/message/100/attachment/1.2") + + +async def test_empty_data_returns_empty_list(mocker): + """A null ocs.data payload degrades to an empty list for list endpoints.""" + mock_response = _ocs_response(None) + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mocker.patch.object(MailClient, "_make_request", return_value=mock_response) + + client = MailClient(mock_client, "testuser") + assert await client.list_accounts() == [] diff --git a/tests/unit/search/test_verification.py b/tests/unit/search/test_verification.py index bc79925f..f14f5999 100644 --- a/tests/unit/search/test_verification.py +++ b/tests/unit/search/test_verification.py @@ -12,6 +12,7 @@ from nextcloud_mcp_server.search.algorithms import SearchResult from nextcloud_mcp_server.search.verification import ( _verify_deck_cards, _verify_files, + _verify_mail_messages, _verify_news_items, _verify_notes, get_supported_doc_types, @@ -222,6 +223,97 @@ async def test_verify_notes_string_doc_id_matches_production(mocker): notes_client.get_note.assert_awaited_once_with(42) +# --------------------------------------------------------------------------- +# Mail verifier (per-id, mirrors the note verifier) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +async def test_verify_mail_200_keeps_all(mocker): + mail_client = SimpleNamespace(get_message=mocker.AsyncMock(return_value={"id": 42})) + client = SimpleNamespace(mail=mail_client, username="alice") + + result = await _verify_mail_messages( + client, [_make_result(42, doc_type="mail_message")], _sem() + ) + assert result == {"42"} + mail_client.get_message.assert_awaited_once_with(42) + + +@pytest.mark.unit +async def test_verify_mail_404_drops(mocker): + mail_client = SimpleNamespace( + get_message=mocker.AsyncMock(side_effect=_http_error(404)) + ) + client = SimpleNamespace(mail=mail_client, username="alice") + + result = await _verify_mail_messages( + client, [_make_result(42, doc_type="mail_message")], _sem() + ) + assert result == set() + + +@pytest.mark.unit +async def test_verify_mail_403_drops(mocker): + mail_client = SimpleNamespace( + get_message=mocker.AsyncMock(side_effect=_http_error(403)) + ) + client = SimpleNamespace(mail=mail_client, username="alice") + + result = await _verify_mail_messages( + client, [_make_result(42, doc_type="mail_message")], _sem() + ) + assert result == set() + + +@pytest.mark.unit +async def test_verify_mail_transient_5xx_keeps(mocker): + mail_client = SimpleNamespace( + get_message=mocker.AsyncMock(side_effect=_http_error(503)) + ) + client = SimpleNamespace(mail=mail_client, username="alice") + + result = await _verify_mail_messages( + client, [_make_result(42, doc_type="mail_message")], _sem() + ) + assert result == {"42"} + + +@pytest.mark.unit +async def test_verify_mail_non_numeric_id_keeps(mocker): + mail_client = SimpleNamespace(get_message=mocker.AsyncMock()) + client = SimpleNamespace(mail=mail_client, username="alice") + + result = await _verify_mail_messages( + client, [_make_result("not-a-number", doc_type="mail_message")], _sem() + ) + assert result == {"not-a-number"} + # Malformed id is kept without any network call. + mail_client.get_message.assert_not_awaited() + + +@pytest.mark.unit +async def test_verify_mail_mixed_outcomes(mocker): + async def fake_get(message_id: int): + if message_id == 20: + raise _http_error(404) # deleted + return {"id": message_id} + + mail_client = SimpleNamespace(get_message=mocker.AsyncMock(side_effect=fake_get)) + client = SimpleNamespace(mail=mail_client, username="alice") + + result = await _verify_mail_messages( + client, + [ + _make_result(10, doc_type="mail_message"), + _make_result(20, doc_type="mail_message"), + _make_result(30, doc_type="mail_message"), + ], + _sem(), + ) + assert result == {"10", "30"} + + # --------------------------------------------------------------------------- # News batch verifier # --------------------------------------------------------------------------- diff --git a/tests/unit/test_mail_models.py b/tests/unit/test_mail_models.py new file mode 100644 index 00000000..8ff6a3e3 --- /dev/null +++ b/tests/unit/test_mail_models.py @@ -0,0 +1,109 @@ +"""Unit tests for Mail Pydantic models (alias mapping from the OCS API).""" + +import pytest + +from nextcloud_mcp_server.models.mail import ( + GetMessageResponse, + ListAccountsResponse, + MailAccount, + MailMailbox, + MailMessage, + MailMessageSummary, +) + +pytestmark = pytest.mark.unit + + +def test_account_maps_is_delegated_alias(): + account = MailAccount(**{"id": 1, "email": "a@example.com", "isDelegated": True}) + assert account.id == 1 + assert account.is_delegated is True + + +def test_mailbox_maps_camelcase_aliases(): + mailbox = MailMailbox( + **{ + "databaseId": 10, + "id": "SU5CT1g=", + "name": "INBOX", + "displayName": "Inbox", + "accountId": 1, + "specialUse": ["inbox"], + "unread": 5, + } + ) + assert mailbox.database_id == 10 + assert mailbox.account_id == 1 + assert mailbox.display_name == "Inbox" + assert mailbox.special_use == ["inbox"] + assert mailbox.unread == 5 + + +def test_message_summary_maps_from_and_dateint(): + summary = MailMessageSummary( + **{ + "databaseId": 100, + "subject": "Hello", + "dateInt": 1700000000, + "from": [{"label": "Alice", "email": "alice@example.com"}], + "to": [{"email": "bob@example.com"}], + "mailboxId": 10, + "previewText": "snippet", + "flags": {"seen": True, "hasAttachments": True}, + } + ) + assert summary.database_id == 100 + assert summary.date_int == 1700000000 + assert summary.from_[0].label == "Alice" + assert summary.to[0].email == "bob@example.com" + assert summary.mailbox_id == 10 + assert summary.preview_text == "snippet" + assert summary.flags is not None + assert summary.flags.seen is True + assert summary.flags.has_attachments is True + + +def test_full_message_maps_body_and_attachments(): + message = MailMessage( + **{ + "id": 100, + "subject": "Hello", + "hasHtmlBody": True, + "body": "

Hi

", + "from": [{"label": "Alice", "email": "alice@example.com"}], + "attachments": [ + { + "id": "1.2", + "fileName": "doc.pdf", + "mime": "application/pdf", + "size": 1024, + } + ], + } + ) + assert message.id == 100 + assert message.has_html_body is True + assert message.body == "

Hi

" + assert message.attachments[0].file_name == "doc.pdf" + assert message.attachments[0].id == "1.2" + + +def test_message_tolerates_missing_optional_fields(): + """A 206 partial response may omit the body.""" + message = MailMessage(**{"id": 100}) + assert message.id == 100 + assert message.body is None + assert message.has_html_body is False + assert message.attachments == [] + + +def test_response_models_wrap_results(): + resp = ListAccountsResponse( + results=[MailAccount(id=1, email="a@example.com")], total_count=1 + ) + assert resp.success is True + assert resp.total_count == 1 + assert resp.results[0].email == "a@example.com" + + msg_resp = GetMessageResponse(message=MailMessage(id=5, subject="Hi")) + assert msg_resp.message.id == 5 diff --git a/tests/unit/vector/test_scanner_consent_backstop.py b/tests/unit/vector/test_scanner_consent_backstop.py index e87ebed9..5205e3d2 100644 --- a/tests/unit/vector/test_scanner_consent_backstop.py +++ b/tests/unit/vector/test_scanner_consent_backstop.py @@ -104,7 +104,9 @@ async def test_noop_when_allowed_is_none(monkeypatch): async def test_noop_when_all_text_types_allowed(monkeypatch): send = AsyncMock() - allowed = frozenset({"note", "news_item", "deck_card", "file"}) + # Derive from INDEXED_DOC_TYPES so a newly-indexed text type doesn't make + # this "all allowed" set silently incomplete (and trip the backstop). + allowed = frozenset(scanner_module.INDEXED_DOC_TYPES) queued = await _enqueue_deletes_for_disabled_types( "alice", _producer(send), allowed, 1 )