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