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
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user