No blockers raised; residual cleanup: - processor.py: guard int(doc_task.doc_id) with is_valid_nextcloud_doc_id in the mail_message branch (consistent with search/context.py + the verifier). - mail metadata symmetry: store `bcc` in file_metadata and the Qdrant payload alongside cc (build_mail_content already emits a Bcc: line). - server/mail.py: extract _cap_attachment_content helper (byte-accurate cap) and unit-test it (small/None/oversized/multibyte). - client/mail.py: give the synthetic OCS-error Response an explicit empty body; add a test that a traversal-style attachment_id is percent-encoded. - models/mail.py: clarify ListMessagesResponse.total_count is the page count, not the mailbox total. Deferred (Deck #376): _potentially_deleted doc_type-in-key. It's pre-existing and spans ~30 sites across the notes/news/deck/file/mail scanners (whose deletion paths have no unit coverage), so it belongs in its own focused PR rather than expanding this mail PR's blast radius into other doc types. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
6.9 KiB
Python
192 lines
6.9 KiB
Python
"""Pydantic models for Nextcloud Mail app responses (read-only)."""
|
|
|
|
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 in this page (NOT the mailbox "
|
|
"total, which isn't known without a full scan); page with cursor and "
|
|
"stop on an empty result"
|
|
)
|
|
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.
|
|
|
|
Intentionally does NOT nest ``MailAttachment``: the Mail OCS *get-attachment*
|
|
endpoint returns a different shape (``name``/``mime``/``size``/``content``)
|
|
than the attachment entries on a message listing, which ``MailAttachment``
|
|
models (``id``/``fileName``/``cid``/``disposition``, and no ``content``).
|
|
"""
|
|
|
|
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")
|