perf(mail): batch verify-on-read; test build_mail_content; addr-recall

Address PR #935 round-3 review:

- search/verification.py: rewrite _verify_mail_messages to batch by mailbox.
  get_message triggers a server-side IMAP body fetch, so per-result verify
  issued one IMAP FETCH per hit; now it calls the DB-cached list_messages once
  per mailbox (mailbox_id comes from the Qdrant payload via result.metadata)
  and intersects — O(unique mailboxes) light calls instead of O(results) IMAP.
- vector/mail_content.py: include Cc/Bcc in the indexed text so recipient
  queries match; move MAIL_SCAN_MAX_PER_MAILBOX here (shared by scanner index
  window + verifier presence window) with a note that it equals the Mail OCS
  per-request max (100), so it's a fixed constant not a config knob.
- client/mail.py: clamp list_messages limit to 1..100 at the client layer.
- tests: add test_mail_content.py (exact-layout contract for build_mail_content);
  rewrite the mail verifier tests for the batch-per-mailbox shape.

Left as-is: ValidationError isn't caught in the list-endpoint tools — consistent
with nc_notes_*/nc_deck_* and not a regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-20 13:01:21 +02:00
co-authored by Claude Opus 4.8
parent c62ccf3d0d
commit 891d07db12
6 changed files with 244 additions and 69 deletions
+4 -1
View File
@@ -142,7 +142,10 @@ class MailClient(BaseNextcloudClient):
List of message summary objects (keys include databaseId, subject, List of message summary objects (keys include databaseId, subject,
from, to, dateInt, flags, previewText, mailboxId). from, to, dateInt, flags, previewText, mailboxId).
""" """
params: dict[str, Any] = {"limit": limit} # The Mail OCS API clamps limit to 1..100 server-side; do it here too so
# the contract is enforced at the client layer with a predictable value
# (a limit of 0 would otherwise collapse to 1 server-side).
params: dict[str, Any] = {"limit": min(max(1, limit), 100)}
if cursor is not None: if cursor is not None:
params["cursor"] = cursor params["cursor"] = cursor
if search_filter is not None: if search_filter is not None:
+78 -24
View File
@@ -53,6 +53,7 @@ from nextcloud_mcp_server.search.algorithms import (
) )
from nextcloud_mcp_server.utils.validation import is_valid_nextcloud_doc_id from nextcloud_mcp_server.utils.validation import is_valid_nextcloud_doc_id
from nextcloud_mcp_server.vector.eviction import delete_document_points from nextcloud_mcp_server.vector.eviction import delete_document_points
from nextcloud_mcp_server.vector.mail_content import MAIL_SCAN_MAX_PER_MAILBOX
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -493,53 +494,106 @@ async def _verify_mail_messages(
results: list[SearchResult], results: list[SearchResult],
semaphore: anyio.Semaphore, semaphore: anyio.Semaphore,
) -> set[str]: ) -> set[str]:
"""Verify mail messages per-id via the Mail OCS ``get_message`` endpoint. """Verify mail messages with one DB-cached list per mailbox, then intersect.
Mirrors ``_verify_notes``: a definitive 403/404 (message deleted, account ``mail.get_message`` triggers a server-side IMAP body fetch, so a per-result
removed, or Mail app disabled) drops the result and schedules eviction; verify would issue one IMAP FETCH per hit — multiple seconds for an active
transient errors and non-numeric ids fail open (keep the result). inbox. Instead we batch by ``mailbox_id`` (propagated into ``result.metadata``
from the Qdrant payload) and call ``mail.list_messages`` once per mailbox,
which reads the Mail app's DB cache (no IMAP). A message is accessible iff it
is present in its mailbox's newest-N listing — the same window the scanner
indexes, so anything aged out of the window is being evicted anyway.
Failure policy mirrors ``_verify_news_items``: a definitive 403/404 for a
mailbox drops all its results (eviction reclaims); transient errors keep
them (fail-open). Results with no/!numeric ``mailbox_id`` or a non-numeric
``doc_id`` are kept (fail-open; verification can't batch them).
""" """
# safe: cooperative concurrency, no lock needed (see verify_search_results) # safe: cooperative concurrency, no lock needed (see verify_search_results)
accessible: set[str] = set() accessible: set[str] = set()
async def check(result: SearchResult) -> None: # Partition results by mailbox so each mailbox is listed exactly once.
doc_id = result.id by_mailbox: dict[int, list[SearchResult]] = {}
try: for r in results:
message_id_int = int(doc_id) mailbox_id = (r.metadata or {}).get("mailbox_id")
except (TypeError, ValueError) as e: # No usable mailbox_id (legacy payload) — can't batch via the DB cache
# without an IMAP-triggering per-id fetch, so keep it (fail-open).
if mailbox_id is None:
logger.warning( logger.warning(
"Non-numeric mail message id %r: %s; keeping result", "Mail result %s has no mailbox_id; keeping (batch verification "
doc_id, "skipped)",
e, r.id,
) )
accessible.add(doc_id) accessible.add(r.id)
return continue
try:
mailbox_int = int(mailbox_id)
except (TypeError, ValueError):
logger.warning(
"Mail result %s has non-numeric mailbox_id %r; keeping "
"(batch verification skipped)",
r.id,
mailbox_id,
)
accessible.add(r.id)
continue
by_mailbox.setdefault(mailbox_int, []).append(r)
async def check_mailbox(mailbox_id: int, mb_results: list[SearchResult]) -> None:
async with semaphore: async with semaphore:
try: try:
await client.mail.get_message(message_id_int) messages = await client.mail.list_messages(
accessible.add(doc_id) mailbox_id, limit=MAIL_SCAN_MAX_PER_MAILBOX
)
except HTTPStatusError as e: except HTTPStatusError as e:
if _is_definitive_404_or_403(e): if _is_definitive_404_or_403(e):
# Mailbox/account gone — all its results are inaccessible.
return return
logger.warning( logger.warning(
"Transient error verifying mail message %s: %s %s; keeping result", "Transient error listing mailbox %s for verification: %s %s; "
doc_id, "keeping its %d result(s)",
mailbox_id,
e.response.status_code, e.response.status_code,
e, e,
len(mb_results),
) )
accessible.add(doc_id) for r in mb_results:
accessible.add(r.id)
return
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Unexpected error verifying mail message %s: %s; keeping result", "Unexpected error listing mailbox %s for verification: %s; "
doc_id, "keeping its %d result(s)",
mailbox_id,
e, e,
len(mb_results),
) )
accessible.add(doc_id) for r in mb_results:
accessible.add(r.id)
return
present_ids = {
str(m.get("databaseId"))
for m in messages
if m.get("databaseId") is not None
}
for r in mb_results:
if r.id in present_ids:
accessible.add(r.id)
elif not is_valid_nextcloud_doc_id(r.id):
# Malformed stored id can't match the numeric listing; keep it
# (fail-open) rather than drop a possibly-legitimate result —
# mirrors the notes/news posture.
logger.warning(
"Malformed mail_message doc_id %r in verifier; keeping",
r.id,
)
accessible.add(r.id)
# else: genuinely absent (deleted or aged out) -> drop + evict.
async with anyio.create_task_group() as tg: async with anyio.create_task_group() as tg:
for r in results: for mailbox_id, mb_results in by_mailbox.items():
tg.start_soon(check, r) tg.start_soon(check_mailbox, mailbox_id, mb_results)
return accessible return accessible
+18 -2
View File
@@ -10,6 +10,13 @@ from typing import Any
from nextcloud_mcp_server.vector.html_processor import html_to_markdown from nextcloud_mcp_server.vector.html_processor import html_to_markdown
# Newest-N messages indexed (and verified) per mailbox. This equals the Mail
# OCS API's per-request maximum (it clamps ``limit`` to 1..100), so it cannot be
# raised without adding cursor pagination — hence a documented constant rather
# than a config knob that would silently cap at 100. Shared by the scanner
# (index window) and the verifier (presence window) so they stay consistent.
MAIL_SCAN_MAX_PER_MAILBOX = 100
def format_mail_addresses(addrs: list[dict[str, Any]] | None) -> str: def format_mail_addresses(addrs: list[dict[str, Any]] | None) -> str:
"""Render a list of {label, email} address objects as a display string.""" """Render a list of {label, email} address objects as a display string."""
@@ -33,16 +40,21 @@ def build_mail_content(message: dict[str, Any]) -> str:
<subject> <subject>
From: <from> From: <from>
To: <to> To: <to>
Cc: <cc> # only when non-empty
Bcc: <bcc> # only when non-empty
<blank line> <blank line>
<body> <body>
The body is the Mail OCS ``body`` field — sanitized HTML when Cc/Bcc are included so recipient-oriented queries ("emails where alice was
``hasHtmlBody`` is set (converted to Markdown for embedding), otherwise cc'd") can match. The body is the Mail OCS ``body`` field — sanitized HTML
when ``hasHtmlBody`` is set (converted to Markdown for embedding), otherwise
plain text. plain text.
""" """
subject = message.get("subject") or "" subject = message.get("subject") or ""
from_str = format_mail_addresses(message.get("from")) from_str = format_mail_addresses(message.get("from"))
to_str = format_mail_addresses(message.get("to")) to_str = format_mail_addresses(message.get("to"))
cc_str = format_mail_addresses(message.get("cc"))
bcc_str = format_mail_addresses(message.get("bcc"))
raw_body = message.get("body") or "" raw_body = message.get("body") or ""
body_text = html_to_markdown(raw_body) if message.get("hasHtmlBody") else raw_body body_text = html_to_markdown(raw_body) if message.get("hasHtmlBody") else raw_body
@@ -51,6 +63,10 @@ def build_mail_content(message: dict[str, Any]) -> str:
content_parts.append(f"From: {from_str}") content_parts.append(f"From: {from_str}")
if to_str: if to_str:
content_parts.append(f"To: {to_str}") content_parts.append(f"To: {to_str}")
if cc_str:
content_parts.append(f"Cc: {cc_str}")
if bcc_str:
content_parts.append(f"Bcc: {bcc_str}")
content_parts.append("") # Blank line content_parts.append("") # Blank line
content_parts.append(body_text) content_parts.append(body_text)
return "\n".join(content_parts) return "\n".join(content_parts)
+8 -7
View File
@@ -28,6 +28,7 @@ from nextcloud_mcp_server.server.tag_exclusion import (
is_path_excluded, is_path_excluded,
) )
from nextcloud_mcp_server.vector.dead_letter import is_dead_lettered from nextcloud_mcp_server.vector.dead_letter import is_dead_lettered
from nextcloud_mcp_server.vector.mail_content import MAIL_SCAN_MAX_PER_MAILBOX
from nextcloud_mcp_server.vector.placeholder import ( from nextcloud_mcp_server.vector.placeholder import (
query_document_metadata, query_document_metadata,
write_placeholder_point, write_placeholder_point,
@@ -1355,13 +1356,13 @@ async def scan_news_items(
return queued return queued
# Newest-N messages indexed per mailbox. The Mail app paginates by recency, so # Newest-N messages indexed per mailbox (= the Mail OCS per-request maximum;
# this bounds the index to recent mail; older messages age out of the index as # imported from mail_content so the scanner index window and the search-time
# newer ones arrive (and the deletion-tracking pass below evicts them, the same # verifier presence window stay identical). Older messages age out of the index
# way it handles actually-deleted messages). Raise this if deeper history is # as newer ones arrive — the deletion-tracking pass below evicts them, the same
# wanted, at the cost of more embedding work. # way it handles actually-deleted messages. Going beyond 100 would require
MAIL_SCAN_MAX_PER_MAILBOX = 100 # cursor pagination, so the value is fixed rather than configurable.
#
# Per-process record of (user_id, mailbox_id) for which the newest-N cap has # Per-process record of (user_id, mailbox_id) for which the newest-N cap has
# already been logged, so the "older mail not indexed" notice is emitted once at # already been logged, so the "older mail not indexed" notice is emitted once at
# info level (discoverable) rather than on every scan tick (which would flood # info level (discoverable) rather than on every scan tick (which would flood
+57 -35
View File
@@ -18,6 +18,7 @@ from nextcloud_mcp_server.search.verification import (
get_supported_doc_types, get_supported_doc_types,
verify_search_results, verify_search_results,
) )
from nextcloud_mcp_server.vector.mail_content import MAIL_SCAN_MAX_PER_MAILBOX
from nextcloud_mcp_server.vector.scanner import INDEXED_DOC_TYPES from nextcloud_mcp_server.vector.scanner import INDEXED_DOC_TYPES
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -228,90 +229,111 @@ async def test_verify_notes_string_doc_id_matches_production(mocker):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _mail_result(doc_id, mailbox_id=10):
"""A mail_message SearchResult carrying mailbox_id in its metadata."""
return _make_result(
doc_id, doc_type="mail_message", metadata={"mailbox_id": mailbox_id}
)
@pytest.mark.unit @pytest.mark.unit
async def test_verify_mail_200_keeps_all(mocker): async def test_verify_mail_batches_one_list_per_mailbox(mocker):
mail_client = SimpleNamespace(get_message=mocker.AsyncMock(return_value={"id": 42})) """The verifier lists each mailbox once (DB cache, not per-message IMAP)."""
list_messages = mocker.AsyncMock(
return_value=[{"databaseId": 10}, {"databaseId": 30}]
)
mail_client = SimpleNamespace(list_messages=list_messages)
client = SimpleNamespace(mail=mail_client, username="alice") client = SimpleNamespace(mail=mail_client, username="alice")
result = await _verify_mail_messages( result = await _verify_mail_messages(
client, [_make_result(42, doc_type="mail_message")], _sem() client,
[_mail_result(10), _mail_result(20), _mail_result(30)], # all mailbox 10
_sem(),
) )
assert result == {"42"} # 10 and 30 present; 20 absent (deleted/aged out) -> dropped.
mail_client.get_message.assert_awaited_once_with(42) assert result == {"10", "30"}
# One DB-cached list call for the single mailbox, not one per result.
list_messages.assert_awaited_once_with(10, limit=MAIL_SCAN_MAX_PER_MAILBOX)
@pytest.mark.unit @pytest.mark.unit
async def test_verify_mail_404_drops(mocker): async def test_verify_mail_404_drops_mailbox(mocker):
mail_client = SimpleNamespace( mail_client = SimpleNamespace(
get_message=mocker.AsyncMock(side_effect=_http_error(404)) list_messages=mocker.AsyncMock(side_effect=_http_error(404))
) )
client = SimpleNamespace(mail=mail_client, username="alice") client = SimpleNamespace(mail=mail_client, username="alice")
result = await _verify_mail_messages( result = await _verify_mail_messages(client, [_mail_result(42)], _sem())
client, [_make_result(42, doc_type="mail_message")], _sem()
)
assert result == set() assert result == set()
@pytest.mark.unit @pytest.mark.unit
async def test_verify_mail_403_drops(mocker): async def test_verify_mail_403_drops_mailbox(mocker):
mail_client = SimpleNamespace( mail_client = SimpleNamespace(
get_message=mocker.AsyncMock(side_effect=_http_error(403)) list_messages=mocker.AsyncMock(side_effect=_http_error(403))
) )
client = SimpleNamespace(mail=mail_client, username="alice") client = SimpleNamespace(mail=mail_client, username="alice")
result = await _verify_mail_messages( result = await _verify_mail_messages(client, [_mail_result(42)], _sem())
client, [_make_result(42, doc_type="mail_message")], _sem()
)
assert result == set() assert result == set()
@pytest.mark.unit @pytest.mark.unit
async def test_verify_mail_transient_5xx_keeps(mocker): async def test_verify_mail_transient_5xx_keeps(mocker):
mail_client = SimpleNamespace( mail_client = SimpleNamespace(
get_message=mocker.AsyncMock(side_effect=_http_error(503)) list_messages=mocker.AsyncMock(side_effect=_http_error(503))
) )
client = SimpleNamespace(mail=mail_client, username="alice") client = SimpleNamespace(mail=mail_client, username="alice")
result = await _verify_mail_messages(client, [_mail_result(42)], _sem())
assert result == {"42"}
@pytest.mark.unit
async def test_verify_mail_missing_mailbox_id_keeps(mocker):
"""A result without a usable mailbox_id is kept without any network call."""
list_messages = mocker.AsyncMock()
mail_client = SimpleNamespace(list_messages=list_messages)
client = SimpleNamespace(mail=mail_client, username="alice")
result = await _verify_mail_messages( result = await _verify_mail_messages(
client, [_make_result(42, doc_type="mail_message")], _sem() client, [_make_result(42, doc_type="mail_message")], _sem()
) )
assert result == {"42"} assert result == {"42"}
list_messages.assert_not_awaited()
@pytest.mark.unit @pytest.mark.unit
async def test_verify_mail_non_numeric_id_keeps(mocker): async def test_verify_mail_non_numeric_id_kept_when_mailbox_listed(mocker):
mail_client = SimpleNamespace(get_message=mocker.AsyncMock()) """A malformed doc_id can't match the numeric listing, so it's kept."""
mail_client = SimpleNamespace(
list_messages=mocker.AsyncMock(return_value=[{"databaseId": 99}])
)
client = SimpleNamespace(mail=mail_client, username="alice") client = SimpleNamespace(mail=mail_client, username="alice")
result = await _verify_mail_messages( result = await _verify_mail_messages(client, [_mail_result("not-a-number")], _sem())
client, [_make_result("not-a-number", doc_type="mail_message")], _sem()
)
assert result == {"not-a-number"} assert result == {"not-a-number"}
# Malformed id is kept without any network call.
mail_client.get_message.assert_not_awaited()
@pytest.mark.unit @pytest.mark.unit
async def test_verify_mail_mixed_outcomes(mocker): async def test_verify_mail_partitions_distinct_mailboxes(mocker):
async def fake_get(message_id: int): """Results in different mailboxes each get their own list call."""
if message_id == 20:
raise _http_error(404) # deleted
return {"id": message_id}
mail_client = SimpleNamespace(get_message=mocker.AsyncMock(side_effect=fake_get)) async def list_messages(mailbox_id, *, limit):
return {10: [{"databaseId": 1}], 20: [{"databaseId": 2}]}[mailbox_id]
mail_client = SimpleNamespace(
list_messages=mocker.AsyncMock(side_effect=list_messages)
)
client = SimpleNamespace(mail=mail_client, username="alice") client = SimpleNamespace(mail=mail_client, username="alice")
result = await _verify_mail_messages( result = await _verify_mail_messages(
client, client,
[ [_mail_result(1, mailbox_id=10), _mail_result(2, mailbox_id=20)],
_make_result(10, doc_type="mail_message"),
_make_result(20, doc_type="mail_message"),
_make_result(30, doc_type="mail_message"),
],
_sem(), _sem(),
) )
assert result == {"10", "30"} assert result == {"1", "2"}
assert mail_client.list_messages.await_count == 2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+79
View File
@@ -0,0 +1,79 @@
"""Unit tests for the shared mail content reconstruction.
``build_mail_content`` is the single source of truth for index-time and
query-time chunk offsets; these tests pin the exact layout so a change to the
separators or header order can't silently misalign every indexed message.
"""
import pytest
from nextcloud_mcp_server.vector.mail_content import (
build_mail_content,
format_mail_addresses,
)
pytestmark = pytest.mark.unit
def test_format_addresses_variants():
assert (
format_mail_addresses([{"label": "Alice", "email": "alice@example.com"}])
== "Alice <alice@example.com>"
)
# Email only, label only, label==email, and multiple joined by ", ".
assert format_mail_addresses([{"email": "bob@example.com"}]) == "bob@example.com"
assert format_mail_addresses([{"label": "Ops"}]) == "Ops"
assert format_mail_addresses([{"label": "x@y.z", "email": "x@y.z"}]) == "x@y.z"
assert format_mail_addresses(None) == ""
assert (
format_mail_addresses([{"email": "a@x.io"}, {"label": "B", "email": "b@x.io"}])
== "a@x.io, B <b@x.io>"
)
def test_build_mail_content_plain_text_layout():
message = {
"subject": "Hello",
"from": [{"label": "Alice", "email": "alice@example.com"}],
"to": [{"email": "bob@example.com"}],
"hasHtmlBody": False,
"body": "Hi there.",
}
assert build_mail_content(message) == (
"Hello\nFrom: Alice <alice@example.com>\nTo: bob@example.com\n\nHi there."
)
def test_build_mail_content_includes_cc_and_bcc_when_present():
message = {
"subject": "Sync",
"from": [{"email": "a@x.io"}],
"to": [{"email": "b@x.io"}],
"cc": [{"email": "c@x.io"}],
"bcc": [{"email": "d@x.io"}],
"hasHtmlBody": False,
"body": "body",
}
assert build_mail_content(message) == (
"Sync\nFrom: a@x.io\nTo: b@x.io\nCc: c@x.io\nBcc: d@x.io\n\nbody"
)
def test_build_mail_content_converts_html_body():
message = {
"subject": "HTML",
"from": [{"email": "a@x.io"}],
"hasHtmlBody": True,
"body": "<p>Hello <strong>world</strong></p>",
}
result = build_mail_content(message)
# Header preserved; body converted to markdown (no raw tags).
assert result.startswith("HTML\nFrom: a@x.io\n\n")
assert "<p>" not in result
assert "world" in result
def test_build_mail_content_tolerates_empty_fields():
# No subject/addresses/body (e.g. a 206 partial) -> just the blank-line +
# empty body, with no spurious header lines.
assert build_mail_content({}) == "\n\n"