harden(mail): address PR #935 round-5 review

No blockers raised; residual cleanup:
- processor.py: guard int(doc_task.doc_id) with is_valid_nextcloud_doc_id in
  the mail_message branch (consistent with search/context.py + the verifier).
- mail metadata symmetry: store `bcc` in file_metadata and the Qdrant payload
  alongside cc (build_mail_content already emits a Bcc: line).
- server/mail.py: extract _cap_attachment_content helper (byte-accurate cap)
  and unit-test it (small/None/oversized/multibyte).
- client/mail.py: give the synthetic OCS-error Response an explicit empty body;
  add a test that a traversal-style attachment_id is percent-encoded.
- models/mail.py: clarify ListMessagesResponse.total_count is the page count,
  not the mailbox total.

Deferred (Deck #376): _potentially_deleted doc_type-in-key. It's pre-existing
and spans ~30 sites across the notes/news/deck/file/mail scanners (whose
deletion paths have no unit coverage), so it belongs in its own focused PR
rather than expanding this mail PR's blast radius into other doc types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-20 13:24:24 +02:00
co-authored by Claude Opus 4.8
parent 0856d59956
commit d006145444
6 changed files with 91 additions and 11 deletions
+18 -9
View File
@@ -32,6 +32,23 @@ logger = logging.getLogger(__name__)
MAX_ATTACHMENT_CONTENT_BYTES = 5 * 1024 * 1024
def _cap_attachment_content(content: str | None) -> str | None:
"""Replace oversized attachment content with a size sentinel.
Measures UTF-8 byte length (what actually lands in the MCP response/LLM
context), not character count. Non-string content is returned unchanged.
"""
if not isinstance(content, str):
return content
content_bytes = len(content.encode("utf-8"))
if content_bytes > MAX_ATTACHMENT_CONTENT_BYTES:
return (
f"[attachment too large to inline: {content_bytes} bytes "
f"(> {MAX_ATTACHMENT_CONTENT_BYTES})]"
)
return content
def configure_mail_tools(mcp: FastMCP):
"""Configure Mail app MCP tools (read-only)."""
@@ -221,19 +238,11 @@ def configure_mail_tools(mcp: FastMCP):
client = await get_client(ctx)
try:
data = await client.mail.get_attachment(message_id, attachment_id)
content = data.get("content")
if isinstance(content, str):
content_bytes = len(content.encode("utf-8"))
if content_bytes > MAX_ATTACHMENT_CONTENT_BYTES:
content = (
f"[attachment too large to inline: {content_bytes} bytes "
f"(> {MAX_ATTACHMENT_CONTENT_BYTES})]"
)
return GetAttachmentResponse(
name=data.get("name"),
mime=data.get("mime"),
size=data.get("size"),
content=content,
content=_cap_attachment_content(data.get("content")),
)
except RequestError as e:
raise McpError(