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
@@ -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,
}