feat(mail): read and index Nextcloud Mail via the Mail OCS API

Add read-only support for the Nextcloud Mail app, plus semantic indexing
of mail messages. The MCP server never speaks IMAP/POP3 itself: it calls
the Mail app's CSRF-free OCS API (/ocs/v2.php/apps/mail/api/...) with the
existing Basic-Auth app-password flow and an OCS-APIRequest header, and the
Mail app handles IMAP server-side.

- client/mail.py: MailClient (accounts, mailboxes, messages, message,
  attachment), OCS-envelope aware.
- models/mail.py: Pydantic models with the API's camelCase aliases.
- server/mail.py: 5 read-only MCP tools (mail.read scope), registered in
  AVAILABLE_APPS.
- Vector pipeline: new "mail_message" doc_type wired into scanner
  (scan_mail_messages, newest-N per mailbox), processor (body -> markdown
  embedding), per-id verifier, and context expansion.
- Tests: client API, model round-trips, verifier behavior; consent-backstop
  test now derives its allowed set from INDEXED_DOC_TYPES.
- README + semantic-search docstrings updated.

Requires Mail 5.x / Nextcloud 32+ and a mail account configured in the
Mail app. Follow-up: astrolabe must advertise "mail_message" in its
enabled_doc_types capability for search under admin doc_type restriction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-20 11:53:47 +02:00
co-authored by Claude Opus 4.8
parent a9d36a8aee
commit 3074622455
18 changed files with 1413 additions and 7 deletions
+54
View File
@@ -840,6 +840,60 @@ async def _index_document(
file_path = None
content_bytes = None
content_type = None
elif doc_task.doc_type == "mail_message":
# Fetch the full message via the Mail OCS API. The Mail app handles
# IMAP server-side; we only ever speak HTTP.
message = await nc_client.mail.get_message(int(doc_task.doc_id))
def _format_addresses(addrs: list[dict] | None) -> str:
parts = []
for addr in addrs or []:
label = addr.get("label")
email = addr.get("email")
if label and email and label != email:
parts.append(f"{label} <{email}>")
elif email:
parts.append(email)
elif label:
parts.append(label)
return ", ".join(parts)
subject = message.get("subject") or ""
from_str = _format_addresses(message.get("from"))
to_str = _format_addresses(message.get("to"))
# Body is sanitized HTML when hasHtmlBody, else plain text. Convert
# HTML to Markdown for better embedding; pass plain text through.
raw_body = message.get("body") or ""
body_text = (
html_to_markdown(raw_body) if message.get("hasHtmlBody") else raw_body
)
content_parts = [subject]
if from_str:
content_parts.append(f"From: {from_str}")
if to_str:
content_parts.append(f"To: {to_str}")
content_parts.append("") # Blank line
content_parts.append(body_text)
content = "\n".join(content_parts)
title = subject
# Email is immutable; key change-detection on the message id so a
# re-index is a no-op unless the id changes.
etag = str(message.get("id") or doc_task.doc_id)
file_metadata = {
"subject": subject,
"from": from_str,
"to": to_str,
"cc": _format_addresses(message.get("cc")),
"date_int": message.get("dateInt"),
"has_attachments": bool(message.get("attachments")),
"account_id": (doc_task.metadata or {}).get("account_id"),
"mailbox_id": (doc_task.metadata or {}).get("mailbox_id"),
}
file_path = None
content_bytes = None
content_type = None
elif doc_task.doc_type == "deck_card":
# Fetch card from Deck API
# Use metadata from scanner if available (O(1) lookup)
+272 -2
View File
@@ -48,7 +48,7 @@ logger = logging.getLogger(__name__)
# same PR that adds a new indexed doc_type, or accept ghost-record exposure for
# that type (see ADR-019).
INDEXED_DOC_TYPES: frozenset[str] = frozenset(
{"note", "file", "deck_card", "news_item"}
{"note", "file", "deck_card", "news_item", "mail_message"}
)
@@ -956,13 +956,36 @@ async def scan_user_documents(
user_id,
)
# Scan Mail messages (newest per mailbox)
mail_queued = 0
if _should_scan("mail", "mail_message", enabled_apps, allowed):
try:
mail_queued = await scan_mail_messages(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
)
queued += mail_queued
except Exception as e:
logger.warning("Failed to scan mail messages for %s: %s", user_id, e)
else:
logger.debug(
"[SCAN-%s] Mail app not enabled for %s; skipping mail messages",
scan_id,
user_id,
)
if queued > 0:
logger.info(
"Sent %s documents (%s files, %s news items, %s deck cards) for incremental sync: %s",
"Sent %s documents (%s files, %s news items, %s deck cards, "
"%s mail messages) for incremental sync: %s",
queued,
file_queued,
news_queued,
deck_queued,
mail_queued,
user_id,
)
else:
@@ -1332,6 +1355,253 @@ 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
async def scan_mail_messages(
user_id: str,
send_stream: TaskProducer,
nc_client: NextcloudClient,
initial_sync: bool,
scan_id: int,
) -> int:
"""
Scan a user's Mail messages and queue changed messages for indexing.
Enumerates accounts → mailboxes → newest ``MAIL_SCAN_MAX_PER_MAILBOX``
messages per mailbox. Email is immutable, so a message's ``dateInt`` (sent
timestamp) is used as the change-detection ``modified_at`` — a message is
indexed once and not re-sent. Messages that drop out of the newest-N window
(or are deleted) are evicted via the deletion-tracking pass, keeping the
index bounded to recent mail.
The MCP server never speaks IMAP: listing reads the Mail app's DB-cached
envelopes, and the body fetch (in the processor) goes through the Mail app's
OCS API, which handles IMAP server-side.
Args:
user_id: User to scan
send_stream: Stream to send changed documents to processors
nc_client: Authenticated Nextcloud client
initial_sync: If True, send all documents (first-time sync)
scan_id: Scan identifier for logging
Returns:
Number of messages queued for processing
"""
settings = get_settings()
queued = 0
# Get indexed mail message IDs from Qdrant (for deletion tracking)
indexed_message_ids: set[str] = set()
if not initial_sync:
qdrant_client = await get_qdrant_client()
points = await _scroll_all_points(
qdrant_client,
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(
key="doc_type", match=MatchValue(value="mail_message")
),
]
),
payload_fields=["doc_id"],
)
indexed_message_ids = {
str(point.payload["doc_id"])
for point in points
if point.payload is not None and "doc_id" in point.payload
}
logger.debug(
"Found %s indexed mail messages in Qdrant", len(indexed_message_ids)
)
# Enumerate accounts → mailboxes → newest-N messages.
accounts = await nc_client.mail.list_accounts()
nextcloud_message_ids: set[str] = set()
message_count = 0
for account in accounts:
account_id = account.get("id")
if account_id is None:
continue
try:
mailboxes = await nc_client.mail.get_mailboxes(account_id)
except Exception as e:
logger.warning(
"[SCAN-%s] Failed to list mailboxes for account %s: %s",
scan_id,
account_id,
e,
)
continue
for mailbox in mailboxes:
mailbox_id = mailbox.get("databaseId")
if mailbox_id is None:
continue
try:
messages = await nc_client.mail.list_messages(
mailbox_id, limit=MAIL_SCAN_MAX_PER_MAILBOX
)
except Exception as e:
logger.warning(
"[SCAN-%s] Failed to list messages for mailbox %s: %s",
scan_id,
mailbox_id,
e,
)
continue
if len(messages) >= MAIL_SCAN_MAX_PER_MAILBOX:
logger.debug(
"[SCAN-%s] Mailbox %s hit the newest-%s cap; older messages "
"are not indexed",
scan_id,
mailbox_id,
MAIL_SCAN_MAX_PER_MAILBOX,
)
for message in messages:
msg_db_id = message.get("databaseId")
if msg_db_id is None:
continue
doc_id = str(msg_db_id)
nextcloud_message_ids.add(doc_id)
message_count += 1
modified_at = message.get("dateInt", 0) or 0
task_metadata: dict[str, int | str] = {
"account_id": account_id,
"mailbox_id": mailbox_id,
}
if initial_sync:
await write_placeholder_point(
doc_id=doc_id,
doc_type="mail_message",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="mail_message",
operation="index",
modified_at=modified_at,
metadata=task_metadata,
)
)
queued += 1
else:
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
logger.debug(
"Mail message %s reappeared, removing from deletion "
"grace period",
doc_id,
)
del _potentially_deleted[doc_key]
existing_metadata = await query_document_metadata(
doc_id=doc_id, doc_type="mail_message", user_id=user_id
)
needs_indexing = False
if existing_metadata is None:
needs_indexing = True
elif existing_metadata.get("modified_at", 0) < modified_at:
needs_indexing = True
elif existing_metadata.get("is_placeholder", False):
queued_at = existing_metadata.get("queued_at", 0)
placeholder_age = time.time() - queued_at
stale_threshold = settings.vector_sync_scan_interval * 5
if placeholder_age > stale_threshold:
logger.debug(
"Found stale placeholder for mail message %s "
"(age=%ss), requeuing",
doc_id,
format(placeholder_age, ".1f"),
)
needs_indexing = True
if needs_indexing:
await write_placeholder_point(
doc_id=doc_id,
doc_type="mail_message",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="mail_message",
operation="index",
modified_at=modified_at,
metadata=task_metadata,
)
)
queued += 1
logger.info(
"[SCAN-%s] Found %s mail messages for %s",
scan_id,
message_count,
user_id,
)
record_vector_sync_scan(message_count)
# Check for deleted / aged-out messages (not initial sync)
if not initial_sync:
grace_period = settings.vector_sync_scan_interval * 1.5
current_time = time.time()
for doc_id in indexed_message_ids:
if doc_id not in nextcloud_message_ids:
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
first_missing_time = _potentially_deleted[doc_key]
time_missing = current_time - first_missing_time
if time_missing >= grace_period:
logger.info(
"Mail message %s missing for %ss (>%ss grace period), "
"sending deletion",
doc_id,
format(time_missing, ".1f"),
format(grace_period, ".1f"),
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="mail_message",
operation="delete",
modified_at=0,
)
)
queued += 1
del _potentially_deleted[doc_key]
else:
logger.debug(
"Mail message %s missing for first time, starting grace period",
doc_id,
)
_potentially_deleted[doc_key] = current_time
return queued
async def scan_deck_cards(
user_id: str,
send_stream: TaskProducer,