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
+19
View File
@@ -196,6 +196,25 @@ async def test_get_attachment_unwraps_json(mocker):
assert args == ("GET", "/ocs/v2.php/apps/mail/api/message/100/attachment/1.2")
async def test_get_attachment_url_encodes_attachment_id(mocker):
"""A traversal-style attachment_id is percent-encoded in the URL path."""
mock_response = _ocs_response({"name": "x", "content": "y"})
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.get_attachment(100, "../../evil")
args, _ = mock_make_request.call_args
# The "/" and ".." are encoded, so they can't escape the attachment path.
assert args == (
"GET",
"/ocs/v2.php/apps/mail/api/message/100/attachment/..%2F..%2Fevil",
)
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)
+38
View File
@@ -0,0 +1,38 @@
"""Unit tests for server/mail.py helper logic."""
import pytest
from nextcloud_mcp_server.server.mail import (
MAX_ATTACHMENT_CONTENT_BYTES,
_cap_attachment_content,
)
pytestmark = pytest.mark.unit
def test_small_content_passes_through():
assert _cap_attachment_content("hello") == "hello"
def test_none_content_passes_through():
assert _cap_attachment_content(None) is None
def test_oversized_content_replaced_with_byte_sentinel():
oversized = "a" * (MAX_ATTACHMENT_CONTENT_BYTES + 1)
result = _cap_attachment_content(oversized)
assert result != oversized
assert "too large to inline" in result
# Reports the actual UTF-8 byte count, not character count.
assert f"{MAX_ATTACHMENT_CONTENT_BYTES + 1} bytes" in result
def test_multibyte_counted_in_bytes_not_chars():
# Each "€" is 3 UTF-8 bytes; a string just under the byte cap in characters
# can still exceed it in bytes.
char_count = (MAX_ATTACHMENT_CONTENT_BYTES // 3) + 1
content = "" * char_count
# Under the cap by character count, over it by byte count -> capped.
assert len(content) <= MAX_ATTACHMENT_CONTENT_BYTES
assert len(content.encode("utf-8")) > MAX_ATTACHMENT_CONTENT_BYTES
assert "too large to inline" in _cap_attachment_content(content)