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:
leocrm-bot
2026-07-01 15:41:27 +02:00
parent e28d11ff70
commit f646c597dc
13 changed files with 4673 additions and 74 deletions
+351
View File
@@ -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