diff --git a/nextcloud_mcp_server/server/mail.py b/nextcloud_mcp_server/server/mail.py index b9ca5f9b..f0abdb3f 100644 --- a/nextcloud_mcp_server/server/mail.py +++ b/nextcloud_mcp_server/server/mail.py @@ -143,15 +143,22 @@ def configure_mail_tools(mcp: FastMCP): messages; page with ``cursor`` and stop on an empty result. """ client = await get_client(ctx) + # Clamp to the same window the client/OCS API enforce so the has_more + # heuristic compares against the limit actually applied (a caller passing + # limit<=0 otherwise gets a misleading count). + effective_limit = min(max(1, limit), 100) try: messages_data = await client.mail.list_messages( - mailbox_id, cursor=cursor, search_filter=search_filter, limit=limit + mailbox_id, + cursor=cursor, + search_filter=search_filter, + limit=effective_limit, ) messages = [MailMessageSummary(**m) for m in messages_data] return ListMessagesResponse( results=messages, total_count=len(messages), - has_more=len(messages) == limit and limit > 0, + has_more=len(messages) == effective_limit, ) except RequestError as e: raise McpError( @@ -181,6 +188,8 @@ def configure_mail_tools(mcp: FastMCP): Returns: GetMessageResponse with the full message including body and attachments. + Attachments with ``id: null`` are inline body parts and cannot be + fetched via nc_mail_get_attachment (which requires a string id). """ client = await get_client(ctx) try: diff --git a/nextcloud_mcp_server/vector/processor.py b/nextcloud_mcp_server/vector/processor.py index 5b2ccf2c..d0486b6f 100644 --- a/nextcloud_mcp_server/vector/processor.py +++ b/nextcloud_mcp_server/vector/processor.py @@ -855,6 +855,13 @@ async def _index_document( if not is_valid_nextcloud_doc_id(doc_task.doc_id): raise ValueError(f"Invalid mail_message doc_id: {doc_task.doc_id!r}") message = await nc_client.mail.get_message(int(doc_task.doc_id)) + # An empty payload (OCS data=null with a <400 meta) would otherwise + # index a useless near-empty placeholder; fail loudly so the task + # dead-letters instead of corrupting the index. + if not message: + raise ValueError( + f"mail_message {doc_task.doc_id!r} returned an empty payload" + ) content = build_mail_content(message) subject = message.get("subject") or "" diff --git a/tests/unit/vector/test_scanner_mail.py b/tests/unit/vector/test_scanner_mail.py index c4cfe39e..df1c1031 100644 --- a/tests/unit/vector/test_scanner_mail.py +++ b/tests/unit/vector/test_scanner_mail.py @@ -229,3 +229,24 @@ async def test_incremental_deletes_after_grace_period(mocker): assert queued == 1 assert [(t.doc_id, t.operation) for t in stream.tasks] == [("999", "delete")] assert ("alice", "999") not in scanner_module._potentially_deleted + + +async def test_incremental_first_missing_starts_grace(mocker): + """A newly-missing indexed message enters the grace period (no delete yet).""" + _patch_incremental(mocker, indexed_ids=["999"], existing_metadata=None) + # Not previously seen as missing, and the mailbox now returns no messages. + nc_client = _single_message_client([]) + + stream = _CollectingStream() + queued = await scan_mail_messages( + user_id="alice", + send_stream=stream, + nc_client=nc_client, + initial_sync=False, + scan_id=1, + ) + + # First miss only starts the grace period — nothing queued, nothing deleted. + assert queued == 0 + assert stream.tasks == [] + assert ("alice", "999") in scanner_module._potentially_deleted