feat(mail): read and index Nextcloud Mail via the Mail OCS API
Add read-only support for the Nextcloud Mail app, plus semantic indexing of mail messages. The MCP server never speaks IMAP/POP3 itself: it calls the Mail app's CSRF-free OCS API (/ocs/v2.php/apps/mail/api/...) with the existing Basic-Auth app-password flow and an OCS-APIRequest header, and the Mail app handles IMAP server-side. - client/mail.py: MailClient (accounts, mailboxes, messages, message, attachment), OCS-envelope aware. - models/mail.py: Pydantic models with the API's camelCase aliases. - server/mail.py: 5 read-only MCP tools (mail.read scope), registered in AVAILABLE_APPS. - Vector pipeline: new "mail_message" doc_type wired into scanner (scan_mail_messages, newest-N per mailbox), processor (body -> markdown embedding), per-id verifier, and context expansion. - Tests: client API, model round-trips, verifier behavior; consent-backstop test now derives its allowed set from INDEXED_DOC_TYPES. - README + semantic-search docstrings updated. Requires Mail 5.x / Nextcloud 32+ and a mail account configured in the Mail app. Follow-up: astrolabe must advertise "mail_message" in its enabled_doc_types capability for search under admin doc_type restriction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a9d36a8aee
commit
3074622455
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user