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>
184 lines
6.4 KiB
Python
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")
|