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
@@ -0,0 +1,206 @@
|
||||
"""Unit tests for MailClient API methods."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client.mail import MailClient
|
||||
from tests.client.conftest import create_mock_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _ocs_response(data: Any, status_code: int = 200) -> httpx.Response:
|
||||
"""Wrap a payload in the standard OCS envelope."""
|
||||
return create_mock_response(
|
||||
status_code=status_code,
|
||||
json_data={
|
||||
"ocs": {
|
||||
"meta": {"status": "ok", "statuscode": status_code, "message": "OK"},
|
||||
"data": data,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_list_accounts_unwraps_ocs_envelope(mocker):
|
||||
"""list_accounts returns the ocs.data payload."""
|
||||
mock_response = _ocs_response(
|
||||
[
|
||||
{"id": 1, "email": "alice@example.com", "isDelegated": False},
|
||||
{"id": 2, "email": "bob@example.com", "isDelegated": False},
|
||||
]
|
||||
)
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
MailClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = MailClient(mock_client, "testuser")
|
||||
accounts = await client.list_accounts()
|
||||
|
||||
assert len(accounts) == 2
|
||||
assert accounts[0]["id"] == 1
|
||||
assert accounts[0]["email"] == "alice@example.com"
|
||||
|
||||
# Correct URL, OCS header, and format=json param.
|
||||
args, kwargs = mock_make_request.call_args
|
||||
assert args == ("GET", "/ocs/v2.php/apps/mail/api/account/list")
|
||||
assert kwargs["headers"]["OCS-APIRequest"] == "true"
|
||||
assert kwargs["params"]["format"] == "json"
|
||||
|
||||
|
||||
async def test_get_mailboxes_passes_account_id(mocker):
|
||||
"""get_mailboxes sends accountId and unwraps the list."""
|
||||
mock_response = _ocs_response(
|
||||
[
|
||||
{
|
||||
"databaseId": 10,
|
||||
"id": "SU5CT1g=",
|
||||
"name": "INBOX",
|
||||
"displayName": "INBOX",
|
||||
"accountId": 1,
|
||||
"specialUse": ["inbox"],
|
||||
"unread": 3,
|
||||
}
|
||||
]
|
||||
)
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
MailClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = MailClient(mock_client, "testuser")
|
||||
mailboxes = await client.get_mailboxes(account_id=1)
|
||||
|
||||
assert len(mailboxes) == 1
|
||||
assert mailboxes[0]["databaseId"] == 10
|
||||
assert mailboxes[0]["specialUse"] == ["inbox"]
|
||||
|
||||
args, kwargs = mock_make_request.call_args
|
||||
assert args == ("GET", "/ocs/v2.php/apps/mail/api/mailboxes")
|
||||
assert kwargs["params"]["accountId"] == 1
|
||||
|
||||
|
||||
async def test_list_messages_builds_params(mocker):
|
||||
"""list_messages forwards limit/cursor/filter/view query params."""
|
||||
mock_response = _ocs_response(
|
||||
[
|
||||
{
|
||||
"databaseId": 100,
|
||||
"subject": "Hello",
|
||||
"dateInt": 1700000000,
|
||||
"from": [{"label": "Alice", "email": "alice@example.com"}],
|
||||
"to": [{"label": "Bob", "email": "bob@example.com"}],
|
||||
"mailboxId": 10,
|
||||
}
|
||||
]
|
||||
)
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
MailClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = MailClient(mock_client, "testuser")
|
||||
messages = await client.list_messages(
|
||||
10, cursor=42, filter="hello", limit=50, view="threaded"
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["databaseId"] == 100
|
||||
|
||||
args, kwargs = mock_make_request.call_args
|
||||
assert args == ("GET", "/ocs/v2.php/apps/mail/api/mailboxes/10/messages")
|
||||
assert kwargs["params"]["limit"] == 50
|
||||
assert kwargs["params"]["cursor"] == 42
|
||||
assert kwargs["params"]["filter"] == "hello"
|
||||
assert kwargs["params"]["view"] == "threaded"
|
||||
|
||||
|
||||
async def test_list_messages_omits_optional_params(mocker):
|
||||
"""Optional params are omitted when not supplied; limit always present."""
|
||||
mock_response = _ocs_response([])
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
MailClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = MailClient(mock_client, "testuser")
|
||||
await client.list_messages(10)
|
||||
|
||||
_, kwargs = mock_make_request.call_args
|
||||
params = kwargs["params"]
|
||||
assert params["limit"] == 20 # default
|
||||
assert "cursor" not in params
|
||||
assert "filter" not in params
|
||||
assert "view" not in params
|
||||
|
||||
|
||||
async def test_get_message_unwraps_full_message(mocker):
|
||||
"""get_message returns the full message dict."""
|
||||
mock_response = _ocs_response(
|
||||
{
|
||||
"id": 100,
|
||||
"subject": "Hello",
|
||||
"hasHtmlBody": True,
|
||||
"body": "<p>Hi there</p>",
|
||||
"from": [{"label": "Alice", "email": "alice@example.com"}],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "1.2",
|
||||
"fileName": "doc.pdf",
|
||||
"mime": "application/pdf",
|
||||
"size": 1024,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
MailClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = MailClient(mock_client, "testuser")
|
||||
message = await client.get_message(100)
|
||||
|
||||
assert message["id"] == 100
|
||||
assert message["hasHtmlBody"] is True
|
||||
assert message["attachments"][0]["fileName"] == "doc.pdf"
|
||||
|
||||
args, _ = mock_make_request.call_args
|
||||
assert args == ("GET", "/ocs/v2.php/apps/mail/api/message/100")
|
||||
|
||||
|
||||
async def test_get_attachment_unwraps_json(mocker):
|
||||
"""get_attachment returns the JSON attachment object (not a binary download)."""
|
||||
mock_response = _ocs_response(
|
||||
{"name": "doc.pdf", "mime": "application/pdf", "size": 1024, "content": "abc"}
|
||||
)
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
MailClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = MailClient(mock_client, "testuser")
|
||||
attachment = await client.get_attachment(100, "1.2")
|
||||
|
||||
assert attachment["name"] == "doc.pdf"
|
||||
assert attachment["content"] == "abc"
|
||||
|
||||
args, _ = mock_make_request.call_args
|
||||
assert args == ("GET", "/ocs/v2.php/apps/mail/api/message/100/attachment/1.2")
|
||||
|
||||
|
||||
async def test_empty_data_returns_empty_list(mocker):
|
||||
"""A null ocs.data payload degrades to an empty list for list endpoints."""
|
||||
mock_response = _ocs_response(None)
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mocker.patch.object(MailClient, "_make_request", return_value=mock_response)
|
||||
|
||||
client = MailClient(mock_client, "testuser")
|
||||
assert await client.list_accounts() == []
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Unit tests for Mail Pydantic models (alias mapping from the OCS API)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.models.mail import (
|
||||
GetMessageResponse,
|
||||
ListAccountsResponse,
|
||||
MailAccount,
|
||||
MailMailbox,
|
||||
MailMessage,
|
||||
MailMessageSummary,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_account_maps_is_delegated_alias():
|
||||
account = MailAccount(**{"id": 1, "email": "a@example.com", "isDelegated": True})
|
||||
assert account.id == 1
|
||||
assert account.is_delegated is True
|
||||
|
||||
|
||||
def test_mailbox_maps_camelcase_aliases():
|
||||
mailbox = MailMailbox(
|
||||
**{
|
||||
"databaseId": 10,
|
||||
"id": "SU5CT1g=",
|
||||
"name": "INBOX",
|
||||
"displayName": "Inbox",
|
||||
"accountId": 1,
|
||||
"specialUse": ["inbox"],
|
||||
"unread": 5,
|
||||
}
|
||||
)
|
||||
assert mailbox.database_id == 10
|
||||
assert mailbox.account_id == 1
|
||||
assert mailbox.display_name == "Inbox"
|
||||
assert mailbox.special_use == ["inbox"]
|
||||
assert mailbox.unread == 5
|
||||
|
||||
|
||||
def test_message_summary_maps_from_and_dateint():
|
||||
summary = MailMessageSummary(
|
||||
**{
|
||||
"databaseId": 100,
|
||||
"subject": "Hello",
|
||||
"dateInt": 1700000000,
|
||||
"from": [{"label": "Alice", "email": "alice@example.com"}],
|
||||
"to": [{"email": "bob@example.com"}],
|
||||
"mailboxId": 10,
|
||||
"previewText": "snippet",
|
||||
"flags": {"seen": True, "hasAttachments": True},
|
||||
}
|
||||
)
|
||||
assert summary.database_id == 100
|
||||
assert summary.date_int == 1700000000
|
||||
assert summary.from_[0].label == "Alice"
|
||||
assert summary.to[0].email == "bob@example.com"
|
||||
assert summary.mailbox_id == 10
|
||||
assert summary.preview_text == "snippet"
|
||||
assert summary.flags is not None
|
||||
assert summary.flags.seen is True
|
||||
assert summary.flags.has_attachments is True
|
||||
|
||||
|
||||
def test_full_message_maps_body_and_attachments():
|
||||
message = MailMessage(
|
||||
**{
|
||||
"id": 100,
|
||||
"subject": "Hello",
|
||||
"hasHtmlBody": True,
|
||||
"body": "<p>Hi</p>",
|
||||
"from": [{"label": "Alice", "email": "alice@example.com"}],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "1.2",
|
||||
"fileName": "doc.pdf",
|
||||
"mime": "application/pdf",
|
||||
"size": 1024,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert message.id == 100
|
||||
assert message.has_html_body is True
|
||||
assert message.body == "<p>Hi</p>"
|
||||
assert message.attachments[0].file_name == "doc.pdf"
|
||||
assert message.attachments[0].id == "1.2"
|
||||
|
||||
|
||||
def test_message_tolerates_missing_optional_fields():
|
||||
"""A 206 partial response may omit the body."""
|
||||
message = MailMessage(**{"id": 100})
|
||||
assert message.id == 100
|
||||
assert message.body is None
|
||||
assert message.has_html_body is False
|
||||
assert message.attachments == []
|
||||
|
||||
|
||||
def test_response_models_wrap_results():
|
||||
resp = ListAccountsResponse(
|
||||
results=[MailAccount(id=1, email="a@example.com")], total_count=1
|
||||
)
|
||||
assert resp.success is True
|
||||
assert resp.total_count == 1
|
||||
assert resp.results[0].email == "a@example.com"
|
||||
|
||||
msg_resp = GetMessageResponse(message=MailMessage(id=5, subject="Hi"))
|
||||
assert msg_resp.message.id == 5
|
||||
@@ -104,7 +104,9 @@ async def test_noop_when_allowed_is_none(monkeypatch):
|
||||
|
||||
async def test_noop_when_all_text_types_allowed(monkeypatch):
|
||||
send = AsyncMock()
|
||||
allowed = frozenset({"note", "news_item", "deck_card", "file"})
|
||||
# Derive from INDEXED_DOC_TYPES so a newly-indexed text type doesn't make
|
||||
# this "all allowed" set silently incomplete (and trip the backstop).
|
||||
allowed = frozenset(scanner_module.INDEXED_DOC_TYPES)
|
||||
queued = await _enqueue_deletes_for_disabled_types(
|
||||
"alice", _producer(send), allowed, 1
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user