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:
Chris Coutinho
2026-06-20 11:53:47 +02:00
co-authored by Claude Opus 4.8
parent a9d36a8aee
commit 3074622455
18 changed files with 1413 additions and 7 deletions
+3 -2
View File
@@ -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 - **110+ MCP Tools** - Comprehensive API coverage across 10 Nextcloud apps
- **MCP Resources** - Structured data URIs for browsing Nextcloud data - **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 - **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 - **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 - **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 | | **Tables** | 5 | Row operations on Nextcloud Tables |
| **Sharing** | 10+ | Create and manage shares | | **Sharing** | 10+ | Create and manage shares |
| **News** | 8 | Feeds, folders, items, feed health monitoring | | **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 | | **Collectives** | 16 | Full CRUD on collectives, pages, and tags |
| **Talk (spreed)** | 6 | List conversations, read/post messages, mark as read, list participants | | **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! Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
+2
View File
@@ -20,6 +20,7 @@ from .contacts import ContactsClient
from .cookbook import CookbookClient from .cookbook import CookbookClient
from .deck import DeckClient from .deck import DeckClient
from .groups import GroupsClient from .groups import GroupsClient
from .mail import MailClient
from .news import NewsClient from .news import NewsClient
from .notes import NotesClient from .notes import NotesClient
from .sharing import SharingClient from .sharing import SharingClient
@@ -144,6 +145,7 @@ class NextcloudClient:
self.collectives = CollectivesClient(self._client, username) self.collectives = CollectivesClient(self._client, username)
self.deck = DeckClient(self._client, username) self.deck = DeckClient(self._client, username)
self.news = NewsClient(self._client, username) self.news = NewsClient(self._client, username)
self.mail = MailClient(self._client, username)
self.talk = TalkClient(self._client, username) self.talk = TalkClient(self._client, username)
self.users = UsersClient(self._client, username) self.users = UsersClient(self._client, username)
self.groups = GroupsClient(self._client, username) self.groups = GroupsClient(self._client, username)
+155
View File
@@ -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": <payload>}}
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 {}
+1
View File
@@ -74,6 +74,7 @@ ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
"sharing.write", "sharing.write",
"news.read", "news.read",
"news.write", "news.write",
"mail.read",
"collectives.read", "collectives.read",
"collectives.write", "collectives.write",
} }
+183
View File
@@ -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")
@@ -76,6 +76,11 @@ class NextcloudClientProtocol(Protocol):
"""News client for accessing news item documents.""" """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 # Top-level client helper (not a sub-client) used by verify-on-read to
# gate file results on current vector-index tag membership. # gate file results on current vector-index tag membership.
async def find_files_by_tag( async def find_files_by_tag(
+42
View File
@@ -822,6 +822,48 @@ async def _fetch_document_text(
if card.description: if card.description:
content_parts.append(card.description) content_parts.append(card.description)
return "\n\n".join(content_parts) 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: else:
logger.warning("Unsupported doc_type for context expansion: %s", doc_type) logger.warning("Unsupported doc_type for context expansion: %s", doc_type)
return None return None
@@ -488,11 +488,68 @@ async def _verify_news_items(
return accessible 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] = { _VERIFIERS: dict[str, BatchVerifier] = {
"note": _verify_notes, "note": _verify_notes,
"file": _verify_files, "file": _verify_files,
"deck_card": _verify_deck_cards, "deck_card": _verify_deck_cards,
"news_item": _verify_news_items, "news_item": _verify_news_items,
"mail_message": _verify_mail_messages,
} }
+3
View File
@@ -7,6 +7,7 @@ from .collectives import configure_collectives_tools
from .contacts import configure_contacts_tools from .contacts import configure_contacts_tools
from .cookbook import configure_cookbook_tools from .cookbook import configure_cookbook_tools
from .deck import configure_deck_tools from .deck import configure_deck_tools
from .mail import configure_mail_tools
from .news import configure_news_tools from .news import configure_news_tools
from .notes import configure_notes_tools from .notes import configure_notes_tools
from .semantic import configure_semantic_tools from .semantic import configure_semantic_tools
@@ -30,6 +31,7 @@ AVAILABLE_APPS: dict[str, Callable[[FastMCP], None]] = {
"cookbook": configure_cookbook_tools, "cookbook": configure_cookbook_tools,
"deck": configure_deck_tools, "deck": configure_deck_tools,
"news": configure_news_tools, "news": configure_news_tools,
"mail": configure_mail_tools,
"talk": configure_talk_tools, "talk": configure_talk_tools,
} }
@@ -40,6 +42,7 @@ __all__ = [
"configure_contacts_tools", "configure_contacts_tools",
"configure_cookbook_tools", "configure_cookbook_tools",
"configure_deck_tools", "configure_deck_tools",
"configure_mail_tools",
"configure_news_tools", "configure_news_tools",
"configure_notes_tools", "configure_notes_tools",
"configure_semantic_tools", "configure_semantic_tools",
+224
View File
@@ -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}",
)
)
+2 -2
View File
@@ -214,12 +214,12 @@ def configure_semantic_tools(mcp: FastMCP):
understanding and keyword precision. understanding and keyword precision.
Requires VECTOR_SYNC_ENABLED=true. Supports indexing of notes, files, Requires VECTOR_SYNC_ENABLED=true. Supports indexing of notes, files,
news items, and deck cards. news items, deck cards, and mail messages.
Args: Args:
query: Natural language or keyword search query query: Natural language or keyword search query
limit: Maximum number of results to return (default: 10) 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) score_threshold: Minimum fusion score (0-1, default: 0.0)
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion) fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
RRF: Good general-purpose fusion using reciprocal ranks RRF: Good general-purpose fusion using reciprocal ranks
+54
View File
@@ -840,6 +840,60 @@ async def _index_document(
file_path = None file_path = None
content_bytes = None content_bytes = None
content_type = 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": elif doc_task.doc_type == "deck_card":
# Fetch card from Deck API # Fetch card from Deck API
# Use metadata from scanner if available (O(1) lookup) # Use metadata from scanner if available (O(1) lookup)
+272 -2
View File
@@ -48,7 +48,7 @@ logger = logging.getLogger(__name__)
# same PR that adds a new indexed doc_type, or accept ghost-record exposure for # same PR that adds a new indexed doc_type, or accept ghost-record exposure for
# that type (see ADR-019). # that type (see ADR-019).
INDEXED_DOC_TYPES: frozenset[str] = frozenset( 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, 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: if queued > 0:
logger.info( 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, queued,
file_queued, file_queued,
news_queued, news_queued,
deck_queued, deck_queued,
mail_queued,
user_id, user_id,
) )
else: else:
@@ -1332,6 +1355,253 @@ async def scan_news_items(
return queued 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( async def scan_deck_cards(
user_id: str, user_id: str,
send_stream: TaskProducer, send_stream: TaskProducer,
View File
+206
View File
@@ -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": "<p>Hi there</p>",
"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() == []
+92
View File
@@ -12,6 +12,7 @@ from nextcloud_mcp_server.search.algorithms import SearchResult
from nextcloud_mcp_server.search.verification import ( from nextcloud_mcp_server.search.verification import (
_verify_deck_cards, _verify_deck_cards,
_verify_files, _verify_files,
_verify_mail_messages,
_verify_news_items, _verify_news_items,
_verify_notes, _verify_notes,
get_supported_doc_types, 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) 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 # News batch verifier
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+109
View File
@@ -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": "<p>Hi</p>",
"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 == "<p>Hi</p>"
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
@@ -104,7 +104,9 @@ async def test_noop_when_allowed_is_none(monkeypatch):
async def test_noop_when_all_text_types_allowed(monkeypatch): async def test_noop_when_all_text_types_allowed(monkeypatch):
send = AsyncMock() 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( queued = await _enqueue_deletes_for_disabled_types(
"alice", _producer(send), allowed, 1 "alice", _producer(send), allowed, 1
) )