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,
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:
params["cursor"] = cursor
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.vector.eviction import delete_document_points
from nextcloud_mcp_server.vector.mail_content import MAIL_SCAN_MAX_PER_MAILBOX
logger = logging.getLogger(__name__)
@@ -493,53 +494,106 @@ async def _verify_mail_messages(
results: list[SearchResult],
semaphore: anyio.Semaphore,
) -> 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
removed, or Mail app disabled) drops the result and schedules eviction;
transient errors and non-numeric ids fail open (keep the result).
``mail.get_message`` triggers a server-side IMAP body fetch, so a per-result
verify would issue one IMAP FETCH per hit — multiple seconds for an active
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)
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:
# Partition results by mailbox so each mailbox is listed exactly once.
by_mailbox: dict[int, list[SearchResult]] = {}
for r in results:
mailbox_id = (r.metadata or {}).get("mailbox_id")
# 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(
"Non-numeric mail message id %r: %s; keeping result",
doc_id,
e,
"Mail result %s has no mailbox_id; keeping (batch verification "
"skipped)",
r.id,
)
accessible.add(doc_id)
return
accessible.add(r.id)
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:
try:
await client.mail.get_message(message_id_int)
accessible.add(doc_id)
messages = await client.mail.list_messages(
mailbox_id, limit=MAIL_SCAN_MAX_PER_MAILBOX
)
except HTTPStatusError as e:
if _is_definitive_404_or_403(e):
# Mailbox/account gone — all its results are inaccessible.
return
logger.warning(
"Transient error verifying mail message %s: %s %s; keeping result",
doc_id,
"Transient error listing mailbox %s for verification: %s %s; "
"keeping its %d result(s)",
mailbox_id,
e.response.status_code,
e,
len(mb_results),
)
accessible.add(doc_id)
for r in mb_results:
accessible.add(r.id)
return
except Exception as e:
logger.warning(
"Unexpected error verifying mail message %s: %s; keeping result",
doc_id,
"Unexpected error listing mailbox %s for verification: %s; "
"keeping its %d result(s)",
mailbox_id,
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:
for r in results:
tg.start_soon(check, r)
for mailbox_id, mb_results in by_mailbox.items():
tg.start_soon(check_mailbox, mailbox_id, mb_results)
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
# 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:
"""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>
From: <from>
To: <to>
Cc: <cc> # only when non-empty
Bcc: <bcc> # only when non-empty
<blank line>
<body>
The body is the Mail OCS ``body`` field — sanitized HTML when
``hasHtmlBody`` is set (converted to Markdown for embedding), otherwise
Cc/Bcc are included so recipient-oriented queries ("emails where alice was
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.
"""
subject = message.get("subject") or ""
from_str = format_mail_addresses(message.get("from"))
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 ""
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}")
if 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(body_text)
return "\n".join(content_parts)
+8 -7
View File
@@ -28,6 +28,7 @@ from nextcloud_mcp_server.server.tag_exclusion import (
is_path_excluded,
)
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 (
query_document_metadata,
write_placeholder_point,
@@ -1355,13 +1356,13 @@ async def scan_news_items(
return queued
# Newest-N messages indexed per mailbox. The Mail app paginates by recency, so
# this bounds the index to recent mail; older messages age out of the index as
# newer ones arrive (and the deletion-tracking pass below evicts them, the same
# way it handles actually-deleted messages). Raise this if deeper history is
# wanted, at the cost of more embedding work.
MAIL_SCAN_MAX_PER_MAILBOX = 100
# Newest-N messages indexed per mailbox (= the Mail OCS per-request maximum;
# imported from mail_content so the scanner index window and the search-time
# verifier presence window stay identical). Older messages age out of the index
# as newer ones arrive — the deletion-tracking pass below evicts them, the same
# way it handles actually-deleted messages. Going beyond 100 would require
# 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
# 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