Files
leocrm/app/plugins/builtins/mail/models.py
T
Agent Zero ed1eec87dc feat: mail phase 2 - delete, move, drafts
- Add deleted_at field to Mail/MailAccount/MailFolder models
- Add DELETE /{mail_id} endpoint (soft-delete + IMAP EXPUNGE)
- Add POST /{mail_id}/move endpoint (IMAP UID MOVE + folder_id update)
- Add POST /drafts and PUT /drafts/{id} endpoints (save/edit drafts)
- Add imap_delete_mail() and imap_move_mail() service functions
- Add save_draft() and update_draft() service functions
- Frontend: deleteMail, moveMail, saveDraft, updateDraft API functions
- ComposeModal: draft mode with Save Draft button
- MailDetail: delete/move buttons, edit-draft for drafts
- Mail.tsx: bulk delete/move, move dropdown, draft handlers
- i18n: 13 new keys (de/en)
2026-07-15 19:44:46 +02:00

405 lines
15 KiB
Python

"""SQLAlchemy models for the Mail plugin."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
# --- Mail Accounts (F-MAIL-14, F-MAIL-18) ---
class MailAccount(Base, TenantMixin):
"""Mail account configuration with encrypted IMAP/SMTP credentials."""
__tablename__ = "mail_accounts"
__table_args__ = (
Index("ix_mail_accounts_user", "user_id"),
Index("ix_mail_accounts_tenant", "tenant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
email_address: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
imap_host: Mapped[str] = mapped_column(String(255), nullable=False)
imap_port: Mapped[int] = mapped_column(Integer, nullable=False, default=993)
imap_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
smtp_host: Mapped[str] = mapped_column(String(255), nullable=False)
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, default=587)
smtp_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
username: Mapped[str] = mapped_column(String(255), nullable=False)
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- Mail Folders (F-MAIL-01, F-MAIL-19) ---
class MailFolder(Base, TenantMixin):
"""IMAP folder mapped to a mail account."""
__tablename__ = "mail_folders"
__table_args__ = (
Index("ix_mail_folders_account", "account_id"),
Index("ix_mail_folders_tenant", "tenant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mail_accounts.id", ondelete="CASCADE"),
nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
imap_name: Mapped[str] = mapped_column(String(255), nullable=False)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mail_folders.id", ondelete="CASCADE"),
nullable=True,
)
is_standard: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
unread_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- Mails (F-MAIL-01, F-MAIL-03, F-MAIL-05) ---
class Mail(Base, TenantMixin):
"""Individual email message stored locally after IMAP sync."""
__tablename__ = "mails"
__table_args__ = (
Index("ix_mails_folder", "folder_id"),
Index("ix_mails_account", "account_id"),
Index("ix_mails_tenant", "tenant_id"),
Index("ix_mails_thread", "thread_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mail_accounts.id", ondelete="CASCADE"),
nullable=False,
)
folder_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mail_folders.id", ondelete="CASCADE"),
nullable=False,
)
message_id: Mapped[str] = mapped_column(String(512), nullable=False)
thread_id: Mapped[str] = mapped_column(String(512), nullable=False, default="")
in_reply_to: Mapped[str | None] = mapped_column(String(512), nullable=True)
references_header: Mapped[str | None] = mapped_column(Text, nullable=True)
subject: Mapped[str] = mapped_column(String(512), nullable=False, default="")
from_address: Mapped[str] = mapped_column(String(255), nullable=False)
to_addresses: Mapped[str] = mapped_column(Text, nullable=False, default="")
cc_addresses: Mapped[str] = mapped_column(Text, nullable=False, default="")
bcc_addresses: Mapped[str] = mapped_column(Text, nullable=False, default="")
body_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
body_html: Mapped[str] = mapped_column(Text, nullable=False, default="")
body_html_sanitized: Mapped[str] = mapped_column(Text, nullable=False, default="")
is_seen: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_flagged: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_draft: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_answered: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_forwarded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
has_attachments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
contact_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
company_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- Mail Attachments (F-MAIL-04) ---
class MailAttachment(Base, TenantMixin):
"""Attachment on a mail, optionally linked to a DMS file."""
__tablename__ = "mail_attachments"
__table_args__ = (
Index("ix_mail_attachments_mail", "mail_id"),
Index("ix_mail_attachments_tenant", "tenant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
mail_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mails.id", ondelete="CASCADE"),
nullable=False,
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(
String(255), nullable=False, default="application/octet-stream"
)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
dms_file_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
content_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
# --- Mail Labels (F-MAIL-09) ---
class MailLabel(Base, TenantMixin):
"""Custom label/tag for mails (colored)."""
__tablename__ = "mail_labels"
__table_args__ = (Index("ix_mail_labels_tenant", "tenant_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str] = mapped_column(String(20), nullable=False, default="#808080")
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
class MailLabelAssignment(Base, TenantMixin):
"""Many-to-many: mail <-> label."""
__tablename__ = "mail_label_assignments"
__table_args__ = (
UniqueConstraint("mail_id", "label_id", name="uq_mail_label_assignment"),
Index("ix_mail_label_assign_mail", "mail_id"),
Index("ix_mail_label_assign_label", "label_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
mail_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mails.id", ondelete="CASCADE"),
nullable=False,
)
label_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mail_labels.id", ondelete="CASCADE"),
nullable=False,
)
# --- Mail Rules (F-MAIL-07) ---
class MailRule(Base, TenantMixin):
"""Filter rule: conditions to actions for incoming mails."""
__tablename__ = "mail_rules"
__table_args__ = (
Index("ix_mail_rules_account", "account_id"),
Index("ix_mail_rules_tenant", "tenant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mail_accounts.id", ondelete="CASCADE"),
nullable=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
conditions: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
actions: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# --- Mail Templates (F-MAIL-06) ---
class MailTemplate(Base, TenantMixin):
"""Email template with placeholder substitution."""
__tablename__ = "mail_templates"
__table_args__ = (Index("ix_mail_templates_tenant", "tenant_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
subject: Mapped[str] = mapped_column(String(512), nullable=False, default="")
body_html: Mapped[str] = mapped_column(Text, nullable=False, default="")
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
# --- Mail Signatures (F-MAIL-13) ---
class MailSignature(Base, TenantMixin):
"""Email signature (HTML) per user, optionally per account."""
__tablename__ = "mail_signatures"
__table_args__ = (Index("ix_mail_signatures_tenant", "tenant_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
account_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mail_accounts.id", ondelete="CASCADE"),
nullable=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
body_html: Mapped[str] = mapped_column(Text, nullable=False, default="")
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# --- Vacation Auto-Reply (F-MAIL-08) ---
class VacationSentLog(Base, TenantMixin):
"""Dedup log for vacation auto-replies (one per sender per 24 hours)."""
__tablename__ = "vacation_sent_log"
__table_args__ = (
Index("ix_vacation_sent_log_account", "account_id"),
Index("ix_vacation_sent_log_tenant", "tenant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mail_accounts.id", ondelete="CASCADE"),
nullable=False,
)
sender_address: Mapped[str] = mapped_column(String(255), nullable=False)
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# --- Seen-By Tracking (F-MAIL-15) ---
class MailSeenBy(Base, TenantMixin):
"""Tracks which users have seen a mail in a shared mailbox."""
__tablename__ = "mail_seen_by"
__table_args__ = (
UniqueConstraint("mail_id", "user_id", name="uq_mail_seen_by"),
Index("ix_mail_seen_by_mail", "mail_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
mail_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mails.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# --- Delegates (F-MAIL-16) ---
class MailAccountDelegate(Base, TenantMixin):
"""Delegate access to a mail account (read or full)."""
__tablename__ = "mail_account_delegates"
__table_args__ = (
UniqueConstraint("account_id", "delegate_user_id", name="uq_mail_delegate"),
Index("ix_mail_delegates_account", "account_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mail_accounts.id", ondelete="CASCADE"),
nullable=False,
)
delegate_user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
access_level: Mapped[str] = mapped_column(String(20), nullable=False, default="read")
# --- Send Permissions (F-MAIL-17) ---
class MailAccountSendPermission(Base, TenantMixin):
"""Permission for a user to send as a shared/group mailbox."""
__tablename__ = "mail_account_send_permissions"
__table_args__ = (
UniqueConstraint("account_id", "user_id", name="uq_mail_send_perm"),
Index("ix_mail_send_perms_account", "account_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("mail_accounts.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
# --- PGP Keys (F-MAIL-12) ---
class PgpKey(Base, TenantMixin):
"""PGP private key for a user (encrypted at rest)."""
__tablename__ = "pgp_keys"
__table_args__ = (Index("ix_pgp_keys_user", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
key_id: Mapped[str] = mapped_column(String(255), nullable=False)
encrypted_private_key: Mapped[str] = mapped_column(Text, nullable=False)
public_key_armored: Mapped[str] = mapped_column(Text, nullable=False)
class ContactPgpKey(Base, TenantMixin):
"""Public PGP key for a contact."""
__tablename__ = "contact_pgp_keys"
__table_args__ = (Index("ix_contact_pgp_keys_contact", "contact_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
contact_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
public_key_armored: Mapped[str] = mapped_column(Text, nullable=False)
key_id: Mapped[str] = mapped_column(String(255), nullable=False, default="")