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:
co-authored by
Claude Opus 4.8
parent
a9d36a8aee
commit
3074622455
@@ -74,6 +74,7 @@ ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
|
||||
"sharing.write",
|
||||
"news.read",
|
||||
"news.write",
|
||||
"mail.read",
|
||||
"collectives.read",
|
||||
"collectives.write",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user