Files
mcp-nextcloud/nextcloud_mcp_server/models/mail.py
T
Chris CoutinhoandClaude Opus 4.8 3074622455 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>
2026-06-20 11:53:47 +02:00

184 lines
6.4 KiB
Python

"""Pydantic models for Nextcloud Mail app responses (read-only)."""
from typing import List
from pydantic import BaseModel, ConfigDict, Field
from .base import BaseResponse
class MailAddress(BaseModel):
"""An email address with an optional display label."""
model_config = ConfigDict(populate_by_name=True)
label: str | None = Field(None, description="Display name")
email: str | None = Field(None, description="Email address")
class MailAccount(BaseModel):
"""A configured mail account (from the account/list endpoint)."""
model_config = ConfigDict(populate_by_name=True)
id: int = Field(description="Account ID")
email: str = Field(description="Account email address")
is_delegated: bool = Field(
False, alias="isDelegated", description="Whether this is a delegated account"
)
class MailMailbox(BaseModel):
"""A mailbox (folder) within an account."""
model_config = ConfigDict(populate_by_name=True)
# ``databaseId`` is the numeric id used by list_messages; ``id`` is a
# base64-encoded mailbox name (a string), so we expose the numeric one.
database_id: int = Field(alias="databaseId", description="Numeric mailbox ID")
name: str = Field(description="IMAP mailbox name (e.g. INBOX, Sent)")
display_name: str | None = Field(
None, alias="displayName", description="Human-readable mailbox name"
)
account_id: int = Field(alias="accountId", description="Parent account ID")
special_use: List[str] = Field(
default_factory=list,
alias="specialUse",
description="Special-use roles (e.g. inbox, sent, trash)",
)
unread: int = Field(0, description="Number of unread messages")
class MailMessageFlags(BaseModel):
"""IMAP flags on a message."""
model_config = ConfigDict(populate_by_name=True)
seen: bool = False
flagged: bool = False
answered: bool = False
deleted: bool = False
draft: bool = False
forwarded: bool = False
has_attachments: bool = Field(False, alias="hasAttachments")
important: bool = False
class MailAttachment(BaseModel):
"""Metadata for a message attachment."""
model_config = ConfigDict(populate_by_name=True)
# Attachment id is a string and may be null for inline/body-part messages.
id: str | None = Field(None, description="Attachment ID")
file_name: str | None = Field(
None, alias="fileName", description="Attachment file name"
)
mime: str | None = Field(None, description="MIME type")
size: int | None = Field(None, description="Size in bytes")
cid: str | None = Field(None, description="Content-ID (for inline attachments)")
disposition: str | None = Field(
None, description="Content disposition (attachment/inline)"
)
class MailMessageSummary(BaseModel):
"""Lightweight message envelope for mailbox listings."""
model_config = ConfigDict(populate_by_name=True)
# ``databaseId`` is the numeric id passed to get_message.
database_id: int = Field(alias="databaseId", description="Numeric message ID")
uid: int | None = Field(None, description="IMAP UID")
subject: str | None = Field(None, description="Message subject")
date_int: int | None = Field(
None, alias="dateInt", description="Sent date as a Unix timestamp (seconds)"
)
from_: List[MailAddress] = Field(
default_factory=list, alias="from", description="Sender addresses"
)
to: List[MailAddress] = Field(
default_factory=list, description="Recipient addresses"
)
mailbox_id: int | None = Field(
None, alias="mailboxId", description="Parent mailbox ID"
)
preview_text: str | None = Field(
None, alias="previewText", description="Short preview snippet"
)
flags: MailMessageFlags | None = Field(None, description="IMAP flags")
class MailMessage(BaseModel):
"""Full message with body (from the message/{id} endpoint)."""
model_config = ConfigDict(populate_by_name=True)
id: int = Field(description="Numeric message ID")
uid: int | None = Field(None, description="IMAP UID")
message_id: str | None = Field(
None, alias="messageId", description="RFC Message-ID header"
)
subject: str | None = Field(None, description="Message subject")
date_int: int | None = Field(
None, alias="dateInt", description="Sent date as a Unix timestamp (seconds)"
)
from_: List[MailAddress] = Field(
default_factory=list, alias="from", description="Sender addresses"
)
to: List[MailAddress] = Field(
default_factory=list, description="Recipient addresses"
)
cc: List[MailAddress] = Field(default_factory=list, description="CC addresses")
bcc: List[MailAddress] = Field(default_factory=list, description="BCC addresses")
has_html_body: bool = Field(
False, alias="hasHtmlBody", description="Whether the body is HTML"
)
body: str | None = Field(
None,
description="Rendered body (sanitized HTML if hasHtmlBody, else plain text)",
)
attachments: List[MailAttachment] = Field(
default_factory=list, description="Message attachments"
)
# --- Response Models ---
class ListAccountsResponse(BaseResponse):
"""Response model for listing mail accounts."""
results: List[MailAccount] = Field(description="List of mail accounts")
total_count: int = Field(description="Total number of accounts")
class ListMailboxesResponse(BaseResponse):
"""Response model for listing mailboxes."""
results: List[MailMailbox] = Field(description="List of mailboxes")
total_count: int = Field(description="Total number of mailboxes")
class ListMessagesResponse(BaseResponse):
"""Response model for listing message envelopes."""
results: List[MailMessageSummary] = Field(description="List of message summaries")
total_count: int = Field(description="Number of messages returned")
has_more: bool = Field(False, description="Whether more messages may exist")
class GetMessageResponse(BaseResponse):
"""Response model for getting a single full message."""
message: MailMessage = Field(description="Full message details")
class GetAttachmentResponse(BaseResponse):
"""Response model for getting a single attachment."""
name: str | None = Field(None, description="Attachment file name")
mime: str | None = Field(None, description="MIME type")
size: int | None = Field(None, description="Size in bytes")
content: str | None = Field(None, description="Attachment content")