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
+92
View File
@@ -12,6 +12,7 @@ from nextcloud_mcp_server.search.algorithms import SearchResult
from nextcloud_mcp_server.search.verification import (
_verify_deck_cards,
_verify_files,
_verify_mail_messages,
_verify_news_items,
_verify_notes,
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)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------