T06: Mail plugin backend — IMAP/SMTP, threading, templates, rules, PGP, vacation, delegates — 46 tests, 74.56% coverage
- 14 SQLAlchemy models (mail_accounts, mail_folders, mails, attachments, labels, rules, templates, signatures, etc.) - AES-256 encrypted credential storage - IMAP sync service (ARQ-ready, sync trigger endpoint) - SMTP send/reply/forward service - Mail rule engine (condition matching → move/label/flag/forward) - Vacation auto-reply with dedup (vacation_sent_log) - PGP integration (key import, encrypt/decrypt, contact public keys) - Shared mailboxes with delegate access + send permissions - HTML sanitization (nh3) - Full-text search (ILIKE fallback, tsvector-ready) - Thread grouping via References/In-Reply-To headers - Template variable substitution - Contact/company auto-linking from email addresses - Calendar event creation from mail - 46 tests covering all 40 acceptance criteria - Ruff lint clean, format clean - Full regression: 527 tests pass (0 failures)
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
"""Pydantic schemas for the Mail plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ─── Mail Accounts ───
|
||||
|
||||
|
||||
class MailAccountCreate(BaseModel):
|
||||
email_address: str = Field(..., min_length=1, max_length=255)
|
||||
display_name: str = Field(default="", max_length=255)
|
||||
imap_host: str = Field(..., min_length=1, max_length=255)
|
||||
imap_port: int = Field(default=993, ge=1, le=65535)
|
||||
imap_ssl: bool = True
|
||||
smtp_host: str = Field(..., min_length=1, max_length=255)
|
||||
smtp_port: int = Field(default=587, ge=1, le=65535)
|
||||
smtp_tls: bool = True
|
||||
username: str = Field(..., min_length=1, max_length=255)
|
||||
password: str = Field(..., min_length=1, max_length=512)
|
||||
is_shared: bool = False
|
||||
|
||||
|
||||
class MailAccountUpdate(BaseModel):
|
||||
email_address: str | None = Field(None, min_length=1, max_length=255)
|
||||
display_name: str | None = Field(None, max_length=255)
|
||||
imap_host: str | None = Field(None, min_length=1, max_length=255)
|
||||
imap_port: int | None = Field(None, ge=1, le=65535)
|
||||
imap_ssl: bool | None = None
|
||||
smtp_host: str | None = Field(None, min_length=1, max_length=255)
|
||||
smtp_port: int | None = Field(None, ge=1, le=65535)
|
||||
smtp_tls: bool | None = None
|
||||
username: str | None = Field(None, min_length=1, max_length=255)
|
||||
password: str | None = Field(None, min_length=1, max_length=512)
|
||||
is_shared: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class MailAccountResponse(BaseModel):
|
||||
id: str
|
||||
email_address: str
|
||||
display_name: str
|
||||
imap_host: str
|
||||
imap_port: int
|
||||
imap_ssl: bool
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_tls: bool
|
||||
username: str
|
||||
is_shared: bool
|
||||
is_active: bool
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class SharedMailboxUserAssign(BaseModel):
|
||||
user_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DelegateCreate(BaseModel):
|
||||
delegate_user_id: str
|
||||
access_level: str = Field("read", pattern="^(read|full)$")
|
||||
|
||||
|
||||
class SendPermissionCreate(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
# ─── Mail Folders ───
|
||||
|
||||
|
||||
class MailFolderCreate(BaseModel):
|
||||
account_id: str
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
imap_name: str = Field(..., min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
|
||||
|
||||
class MailFolderUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
|
||||
|
||||
class MailFolderResponse(BaseModel):
|
||||
id: str
|
||||
account_id: str
|
||||
name: str
|
||||
imap_name: str
|
||||
parent_id: str | None = None
|
||||
is_standard: bool
|
||||
unread_count: int
|
||||
total_count: int
|
||||
|
||||
|
||||
# ─── Mails ───
|
||||
|
||||
|
||||
class MailSendRequest(BaseModel):
|
||||
account_id: str
|
||||
to: list[str] = Field(..., min_length=1)
|
||||
cc: list[str] = Field(default_factory=list)
|
||||
bcc: list[str] = Field(default_factory=list)
|
||||
subject: str = Field(default="", max_length=512)
|
||||
body_html: str = ""
|
||||
body_text: str = ""
|
||||
in_reply_to: str | None = None
|
||||
references_header: str | None = None
|
||||
signature_id: str | None = None
|
||||
template_id: str | None = None
|
||||
template_vars: dict[str, str] = Field(default_factory=dict)
|
||||
attachments: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailReplyRequest(BaseModel):
|
||||
body_html: str = ""
|
||||
body_text: str = ""
|
||||
reply_to_all: bool = False
|
||||
signature_id: str | None = None
|
||||
|
||||
|
||||
class MailForwardRequest(BaseModel):
|
||||
to: list[str] = Field(..., min_length=1)
|
||||
cc: list[str] = Field(default_factory=list)
|
||||
body_html: str = ""
|
||||
body_text: str = ""
|
||||
signature_id: str | None = None
|
||||
|
||||
|
||||
class MailFlagsUpdate(BaseModel):
|
||||
is_seen: bool | None = None
|
||||
is_flagged: bool | None = None
|
||||
is_draft: bool | None = None
|
||||
is_answered: bool | None = None
|
||||
is_forwarded: bool | None = None
|
||||
|
||||
|
||||
class MailResponse(BaseModel):
|
||||
id: str
|
||||
account_id: str
|
||||
folder_id: str
|
||||
message_id: str
|
||||
thread_id: str
|
||||
in_reply_to: str | None = None
|
||||
subject: str
|
||||
from_address: str
|
||||
to_addresses: str
|
||||
cc_addresses: str
|
||||
bcc_addresses: str
|
||||
body_text: str
|
||||
body_html_sanitized: str
|
||||
is_seen: bool
|
||||
is_flagged: bool
|
||||
is_draft: bool
|
||||
is_answered: bool
|
||||
is_forwarded: bool
|
||||
has_attachments: bool
|
||||
size_bytes: int
|
||||
received_at: datetime | None = None
|
||||
sent_at: datetime | None = None
|
||||
contact_id: str | None = None
|
||||
company_id: str | None = None
|
||||
attachments: list[dict] = Field(default_factory=list)
|
||||
labels: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailListResponse(BaseModel):
|
||||
mails: list[MailResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class MailLinkRequest(BaseModel):
|
||||
contact_id: str | None = None
|
||||
company_id: str | None = None
|
||||
|
||||
|
||||
class MailCreateEventRequest(BaseModel):
|
||||
calendar_id: str
|
||||
title: str | None = None
|
||||
start: str | None = None
|
||||
end: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ThreadResponse(BaseModel):
|
||||
thread_id: str
|
||||
subject: str
|
||||
mail_count: int
|
||||
mails: list[MailResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ─── Templates ───
|
||||
|
||||
|
||||
class MailTemplateCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
subject: str = Field(default="", max_length=512)
|
||||
body_html: str = ""
|
||||
|
||||
|
||||
class MailTemplateResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
subject: str
|
||||
body_html: str
|
||||
|
||||
|
||||
class TemplateSubstituteRequest(BaseModel):
|
||||
template_id: str
|
||||
variables: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ─── Signatures ───
|
||||
|
||||
|
||||
class MailSignatureCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
body_html: str = ""
|
||||
account_id: str | None = None
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class MailSignatureResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
body_html: str
|
||||
account_id: str | None = None
|
||||
is_default: bool
|
||||
|
||||
|
||||
# ─── Rules ───
|
||||
|
||||
|
||||
class MailRuleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
account_id: str | None = None
|
||||
priority: int = 0
|
||||
is_active: bool = True
|
||||
conditions: dict = Field(default_factory=dict)
|
||||
actions: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailRuleResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
account_id: str | None = None
|
||||
priority: int
|
||||
is_active: bool
|
||||
conditions: dict
|
||||
actions: dict
|
||||
|
||||
|
||||
# ─── Vacation ───
|
||||
|
||||
|
||||
class VacationConfig(BaseModel):
|
||||
account_id: str
|
||||
is_enabled: bool = True
|
||||
subject: str = Field(default="Out of Office", max_length=512)
|
||||
body_text: str = ""
|
||||
body_html: str = ""
|
||||
start_date: str | None = None
|
||||
end_date: str | None = None
|
||||
|
||||
|
||||
class VacationResponse(BaseModel):
|
||||
is_enabled: bool
|
||||
subject: str
|
||||
body_text: str
|
||||
body_html: str
|
||||
|
||||
|
||||
# ─── PGP ───
|
||||
|
||||
|
||||
class PgpKeyImport(BaseModel):
|
||||
private_key_armored: str = Field(..., min_length=1)
|
||||
passphrase: str = Field(default="", max_length=255)
|
||||
|
||||
|
||||
class PgpKeyResponse(BaseModel):
|
||||
id: str
|
||||
key_id: str
|
||||
public_key_armored: str
|
||||
|
||||
|
||||
class ContactPgpKeyCreate(BaseModel):
|
||||
public_key_armored: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ContactPgpKeyResponse(BaseModel):
|
||||
id: str
|
||||
contact_id: str
|
||||
public_key_armored: str
|
||||
key_id: str
|
||||
|
||||
|
||||
class PgpEncryptRequest(BaseModel):
|
||||
recipient_email: str
|
||||
plaintext: str
|
||||
|
||||
|
||||
class PgpDecryptRequest(BaseModel):
|
||||
ciphertext: str
|
||||
passphrase: str = ""
|
||||
|
||||
|
||||
# ─── Labels ───
|
||||
|
||||
|
||||
class MailLabelCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
color: str = Field(default="#808080", max_length=20)
|
||||
|
||||
|
||||
class MailLabelResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
color: str
|
||||
|
||||
|
||||
class MailLabelAssign(BaseModel):
|
||||
label_id: str
|
||||
|
||||
|
||||
# ─── Search ───
|
||||
|
||||
|
||||
class MailSearchResponse(BaseModel):
|
||||
results: list[MailResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ─── Connection Test ───
|
||||
|
||||
|
||||
class ConnectionTestRequest(BaseModel):
|
||||
imap_host: str
|
||||
imap_port: int = 993
|
||||
smtp_host: str
|
||||
smtp_port: int = 587
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class ConnectionTestResponse(BaseModel):
|
||||
imap_ok: bool
|
||||
smtp_ok: bool
|
||||
message: str
|
||||
Reference in New Issue
Block a user