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
+2
View File
@@ -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)
+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",
"news.read",
"news.write",
"mail.read",
"collectives.read",
"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."""
...
@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(
+42
View File
@@ -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
@@ -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,
}
+3
View File
@@ -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",
+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.
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
+54
View File
@@ -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)
+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
# 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,