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,5 @@
|
||||
"""Mail builtin plugin."""
|
||||
|
||||
from app.plugins.builtins.mail.plugin import MailPlugin
|
||||
|
||||
__all__ = ["MailPlugin"]
|
||||
@@ -0,0 +1,224 @@
|
||||
-- Mail plugin initial migration: creates all mail tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
email_address VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
imap_host VARCHAR(255) NOT NULL,
|
||||
imap_port INTEGER NOT NULL DEFAULT 993,
|
||||
imap_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
smtp_host VARCHAR(255) NOT NULL,
|
||||
smtp_port INTEGER NOT NULL DEFAULT 587,
|
||||
smtp_tls BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
encrypted_password TEXT NOT NULL,
|
||||
is_shared BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_accounts_user ON mail_accounts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_accounts_tenant ON mail_accounts(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
imap_name VARCHAR(255) NOT NULL,
|
||||
parent_id UUID REFERENCES mail_folders(id) ON DELETE CASCADE,
|
||||
is_standard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_folders_account ON mail_folders(account_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_folders_tenant ON mail_folders(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mails (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
folder_id UUID NOT NULL REFERENCES mail_folders(id) ON DELETE CASCADE,
|
||||
message_id VARCHAR(512) NOT NULL,
|
||||
thread_id VARCHAR(512) NOT NULL DEFAULT '',
|
||||
in_reply_to VARCHAR(512),
|
||||
references_header TEXT,
|
||||
subject VARCHAR(512) NOT NULL DEFAULT '',
|
||||
from_address VARCHAR(255) NOT NULL,
|
||||
to_addresses TEXT NOT NULL DEFAULT '',
|
||||
cc_addresses TEXT NOT NULL DEFAULT '',
|
||||
bcc_addresses TEXT NOT NULL DEFAULT '',
|
||||
body_text TEXT NOT NULL DEFAULT '',
|
||||
body_html TEXT NOT NULL DEFAULT '',
|
||||
body_html_sanitized TEXT NOT NULL DEFAULT '',
|
||||
is_seen BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_draft BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_answered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_forwarded BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
has_attachments BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
received_at TIMESTAMPTZ,
|
||||
sent_at TIMESTAMPTZ,
|
||||
contact_id UUID,
|
||||
company_id UUID,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mails_folder ON mails(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_mails_account ON mails(account_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_mails_tenant ON mails(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_mails_thread ON mails(thread_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mail_id UUID NOT NULL REFERENCES mails(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(255) NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
storage_path VARCHAR(1024) NOT NULL,
|
||||
dms_file_id UUID,
|
||||
content_id VARCHAR(255),
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_attachments_mail ON mail_attachments(mail_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_attachments_tenant ON mail_attachments(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_labels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#808080',
|
||||
user_id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_labels_tenant ON mail_labels(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_label_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mail_id UUID NOT NULL REFERENCES mails(id) ON DELETE CASCADE,
|
||||
label_id UUID NOT NULL REFERENCES mail_labels(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_mail_label_assignment UNIQUE (mail_id, label_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_label_assign_mail ON mail_label_assignments(mail_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_label_assign_label ON mail_label_assignments(label_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
conditions TEXT NOT NULL DEFAULT '{}',
|
||||
actions TEXT NOT NULL DEFAULT '{}',
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_rules_account ON mail_rules(account_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_rules_tenant ON mail_rules(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
subject VARCHAR(512) NOT NULL DEFAULT '',
|
||||
body_html TEXT NOT NULL DEFAULT '',
|
||||
user_id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_templates_tenant ON mail_templates(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
account_id UUID REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
body_html TEXT NOT NULL DEFAULT '',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_signatures_tenant ON mail_signatures(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vacation_sent_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
sender_address VARCHAR(255) NOT NULL,
|
||||
sent_at TIMESTAMPTZ NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_vacation_sent_log_account ON vacation_sent_log(account_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_vacation_sent_log_tenant ON vacation_sent_log(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_seen_by (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mail_id UUID NOT NULL REFERENCES mails(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
seen_at TIMESTAMPTZ NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_mail_seen_by UNIQUE (mail_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_seen_by_mail ON mail_seen_by(mail_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_account_delegates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
delegate_user_id UUID NOT NULL,
|
||||
access_level VARCHAR(20) NOT NULL DEFAULT 'read',
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_mail_delegate UNIQUE (account_id, delegate_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_delegates_account ON mail_account_delegates(account_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_account_send_permissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_mail_send_perm UNIQUE (account_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_mail_send_perms_account ON mail_account_send_permissions(account_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pgp_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
key_id VARCHAR(255) NOT NULL,
|
||||
encrypted_private_key TEXT NOT NULL,
|
||||
public_key_armored TEXT NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_pgp_keys_user ON pgp_keys(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_pgp_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contact_id UUID NOT NULL,
|
||||
public_key_armored TEXT NOT NULL,
|
||||
key_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_contact_pgp_keys_contact ON contact_pgp_keys(contact_id);
|
||||
@@ -0,0 +1,401 @@
|
||||
"""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)
|
||||
|
||||
|
||||
# --- 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)
|
||||
|
||||
|
||||
# --- 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)
|
||||
|
||||
|
||||
# --- 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="")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Mail plugin — IMAP/SMTP, threading, templates, rules, PGP, delegates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
|
||||
|
||||
class MailPlugin(BasePlugin):
|
||||
"""Mail plugin for email management: IMAP sync, SMTP send, threading, rules, PGP."""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="mail",
|
||||
version="1.0.0",
|
||||
display_name="Mail",
|
||||
description=(
|
||||
"Email management: IMAP sync, SMTP send, threading, "
|
||||
"templates, rules, vacation, PGP, delegates, labels."
|
||||
),
|
||||
dependencies=[],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/mail",
|
||||
module="app.plugins.builtins.mail.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -0,0 +1,937 @@
|
||||
"""Service layer for the Mail plugin: encryption, IMAP sync, SMTP send, rules, vacation, PGP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email import message_from_bytes
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
|
||||
import aioimaplib
|
||||
import aiosmtplib
|
||||
import nh3
|
||||
import pgpy
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from sqlalchemy import and_, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.mail.models import (
|
||||
Mail,
|
||||
MailAccount,
|
||||
MailAttachment,
|
||||
MailFolder,
|
||||
MailLabel,
|
||||
MailLabelAssignment,
|
||||
MailRule,
|
||||
MailSignature,
|
||||
MailTemplate,
|
||||
VacationSentLog,
|
||||
)
|
||||
|
||||
# ─── AES-256 Encryption (Fernet) ───
|
||||
|
||||
MAIL_ENCRYPTION_KEY = os.environ.get("MAIL_ENCRYPTION_KEY", "leocrm-mail-encryption-key-2024")
|
||||
|
||||
|
||||
def _derive_key(password: str, salt: bytes = b"leocrm-mail-salt") -> bytes:
|
||||
"""Derive a 32-byte Fernet key from a password using PBKDF2."""
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=480000,
|
||||
)
|
||||
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
|
||||
|
||||
|
||||
_fernet = Fernet(_derive_key(MAIL_ENCRYPTION_KEY))
|
||||
|
||||
|
||||
def encrypt_password(plaintext: str) -> str:
|
||||
"""Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext."""
|
||||
return _fernet.encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_password(ciphertext: str) -> str:
|
||||
"""Decrypt a password encrypted with encrypt_password."""
|
||||
return _fernet.decrypt(ciphertext.encode()).decode()
|
||||
|
||||
|
||||
# ─── HTML Sanitization (F-MAIL: no script tags) ───
|
||||
|
||||
|
||||
def sanitize_html(raw_html: str) -> str:
|
||||
"""Sanitize HTML using nh3 — removes script tags and dangerous attributes."""
|
||||
if not raw_html:
|
||||
return ""
|
||||
return nh3.clean(
|
||||
raw_html,
|
||||
tags={
|
||||
"a",
|
||||
"b",
|
||||
"br",
|
||||
"div",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"i",
|
||||
"img",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"span",
|
||||
"strong",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"u",
|
||||
"ul",
|
||||
"blockquote",
|
||||
"code",
|
||||
"pre",
|
||||
"font",
|
||||
"center",
|
||||
},
|
||||
attributes={
|
||||
"a": {"href", "title", "target"},
|
||||
"img": {"src", "alt", "width", "height"},
|
||||
"span": {"style"},
|
||||
"div": {"style"},
|
||||
"font": {"color", "size", "face"},
|
||||
"p": {"style"},
|
||||
"td": {"style"},
|
||||
"th": {"style"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ─── Mail Account Service ───
|
||||
|
||||
|
||||
async def create_mail_account(
|
||||
db: AsyncSession, *, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
|
||||
) -> MailAccount:
|
||||
"""Create a new mail account with encrypted password."""
|
||||
account = MailAccount(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
email_address=data["email_address"],
|
||||
display_name=data.get("display_name", ""),
|
||||
imap_host=data["imap_host"],
|
||||
imap_port=data.get("imap_port", 993),
|
||||
imap_ssl=data.get("imap_ssl", True),
|
||||
smtp_host=data["smtp_host"],
|
||||
smtp_port=data.get("smtp_port", 587),
|
||||
smtp_tls=data.get("smtp_tls", True),
|
||||
username=data["username"],
|
||||
encrypted_password=encrypt_password(data["password"]),
|
||||
is_shared=data.get("is_shared", False),
|
||||
is_active=True,
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
|
||||
# Create standard folders
|
||||
for fname, imap_name in [
|
||||
("Posteingang", "INBOX"),
|
||||
("Postausgang", "Sent"),
|
||||
("Entwürfe", "Drafts"),
|
||||
("Spam", "Spam"),
|
||||
]:
|
||||
folder = MailFolder(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
name=fname,
|
||||
imap_name=imap_name,
|
||||
is_standard=True,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.flush()
|
||||
return account
|
||||
|
||||
|
||||
async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict) -> MailAccount:
|
||||
"""Update a mail account, encrypting password if changed."""
|
||||
for field in [
|
||||
"email_address",
|
||||
"display_name",
|
||||
"imap_host",
|
||||
"imap_port",
|
||||
"imap_ssl",
|
||||
"smtp_host",
|
||||
"smtp_port",
|
||||
"smtp_tls",
|
||||
"username",
|
||||
"is_shared",
|
||||
"is_active",
|
||||
]:
|
||||
if field in data and data[field] is not None:
|
||||
setattr(account, field, data[field])
|
||||
if "password" in data and data["password"] is not None:
|
||||
account.encrypted_password = encrypt_password(data["password"])
|
||||
await db.flush()
|
||||
await db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
async def get_account_password(account: MailAccount) -> str:
|
||||
"""Decrypt and return the account password (internal use only)."""
|
||||
return decrypt_password(account.encrypted_password)
|
||||
|
||||
|
||||
def account_to_response(account: MailAccount) -> dict:
|
||||
"""Convert MailAccount to response dict, NEVER including password."""
|
||||
return {
|
||||
"id": str(account.id),
|
||||
"email_address": account.email_address,
|
||||
"display_name": account.display_name,
|
||||
"imap_host": account.imap_host,
|
||||
"imap_port": account.imap_port,
|
||||
"imap_ssl": account.imap_ssl,
|
||||
"smtp_host": account.smtp_host,
|
||||
"smtp_port": account.smtp_port,
|
||||
"smtp_tls": account.smtp_tls,
|
||||
"username": account.username,
|
||||
"is_shared": account.is_shared,
|
||||
"is_active": account.is_active,
|
||||
"created_at": account.created_at,
|
||||
"updated_at": account.updated_at,
|
||||
}
|
||||
|
||||
|
||||
# ─── IMAP Sync Service (F-MAIL-01) ───
|
||||
|
||||
|
||||
async def imap_sync_account(
|
||||
db: AsyncSession,
|
||||
account_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""Sync mail folders and messages from IMAP server.
|
||||
|
||||
This function is designed to be called as an ARQ background job.
|
||||
In tests it is mocked — real implementation connects via aioimaplib.
|
||||
"""
|
||||
account = (
|
||||
await db.execute(
|
||||
select(MailAccount).where(
|
||||
and_(MailAccount.id == account_id, MailAccount.tenant_id == tenant_id)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not account:
|
||||
return {"synced": 0, "error": "Account not found"}
|
||||
|
||||
password = await get_account_password(account)
|
||||
|
||||
# Import aioimaplib here so tests can mock it
|
||||
try:
|
||||
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
|
||||
await client.wait_hello_from_server()
|
||||
await client.login(account.username, password)
|
||||
await client.select("INBOX")
|
||||
|
||||
# Fetch all message UIDs
|
||||
response = await client.uid_search("ALL")
|
||||
uids = response[1][0].split() if response[1] and response[1][0] else []
|
||||
|
||||
synced_count = 0
|
||||
for uid in uids:
|
||||
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
fetch_resp = await client.uid_fetch(uid_str, "(RFC822)")
|
||||
raw_email = None
|
||||
for line in fetch_resp:
|
||||
if isinstance(line, tuple) and len(line) >= 2:
|
||||
raw_email = line[1]
|
||||
break
|
||||
if raw_email is None:
|
||||
continue
|
||||
if isinstance(raw_email, str):
|
||||
raw_email = raw_email.encode()
|
||||
|
||||
msg = message_from_bytes(raw_email)
|
||||
body_text = ""
|
||||
body_html = ""
|
||||
attachments = []
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
if ct == "text/plain":
|
||||
body_text = part.get_payload(decode=True).decode("utf-8", errors="replace")
|
||||
elif ct == "text/html":
|
||||
body_html = part.get_payload(decode=True).decode("utf-8", errors="replace")
|
||||
elif part.get_filename():
|
||||
attachments.append(
|
||||
{
|
||||
"filename": part.get_filename(),
|
||||
"mime_type": ct,
|
||||
"size": len(part.get_payload(decode=True) or b""),
|
||||
}
|
||||
)
|
||||
else:
|
||||
ct = msg.get_content_type()
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
decoded = payload.decode("utf-8", errors="replace")
|
||||
if ct == "text/html":
|
||||
body_html = decoded
|
||||
else:
|
||||
body_text = decoded
|
||||
|
||||
message_id = msg.get("Message-ID", make_msgid())
|
||||
subject = msg.get("Subject", "")
|
||||
from_addr = msg.get("From", "")
|
||||
to_addrs = msg.get("To", "")
|
||||
cc_addrs = msg.get("Cc", "")
|
||||
refs = msg.get("References", "")
|
||||
in_reply_to = msg.get("In-Reply-To")
|
||||
msg.get("Date", "")
|
||||
|
||||
# Compute thread_id from References/In-Reply-To
|
||||
thread_id = _compute_thread_id(message_id, refs, in_reply_to)
|
||||
|
||||
# Get INBOX folder for this account
|
||||
folder = (
|
||||
await db.execute(
|
||||
select(MailFolder).where(
|
||||
and_(
|
||||
MailFolder.account_id == account.id,
|
||||
MailFolder.imap_name == "INBOX",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if folder is None:
|
||||
continue
|
||||
|
||||
mail = Mail(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
folder_id=folder.id,
|
||||
message_id=message_id,
|
||||
thread_id=thread_id,
|
||||
in_reply_to=in_reply_to,
|
||||
references_header=refs,
|
||||
subject=subject,
|
||||
from_address=from_addr,
|
||||
to_addresses=to_addrs,
|
||||
cc_addresses=cc_addrs,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
body_html_sanitized=sanitize_html(body_html),
|
||||
has_attachments=len(attachments) > 0,
|
||||
size_bytes=len(raw_email),
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(mail)
|
||||
synced_count += 1
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Update folder counts
|
||||
folder = (
|
||||
await db.execute(
|
||||
select(MailFolder).where(
|
||||
and_(
|
||||
MailFolder.account_id == account.id,
|
||||
MailFolder.imap_name == "INBOX",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if folder:
|
||||
total = (
|
||||
await db.execute(
|
||||
select(text("COUNT(*)")).where(
|
||||
and_(Mail.folder_id == folder.id, Mail.tenant_id == tenant_id)
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
unread = (
|
||||
await db.execute(
|
||||
select(text("COUNT(*)")).where(
|
||||
and_(
|
||||
Mail.folder_id == folder.id,
|
||||
Mail.tenant_id == tenant_id,
|
||||
not Mail.is_seen,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
folder.total_count = total
|
||||
folder.unread_count = unread
|
||||
await db.flush()
|
||||
|
||||
await client.logout()
|
||||
return {"synced": synced_count}
|
||||
except Exception as e:
|
||||
return {"synced": 0, "error": str(e)}
|
||||
|
||||
|
||||
def _compute_thread_id(message_id: str, references: str, in_reply_to: str | None) -> str:
|
||||
"""Compute thread ID from References/In-Reply-To headers (F-MAIL-05)."""
|
||||
ref_parts: list[str] = []
|
||||
if references:
|
||||
ref_parts = [r.strip() for r in references.split() if r.strip()]
|
||||
if in_reply_to and in_reply_to.strip() not in ref_parts:
|
||||
ref_parts.append(in_reply_to.strip())
|
||||
if ref_parts:
|
||||
return ref_parts[0]
|
||||
return message_id or str(uuid.uuid4())
|
||||
|
||||
|
||||
# ─── SMTP Send Service (F-MAIL-02) ───
|
||||
|
||||
|
||||
async def send_mail_via_smtp(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
account: MailAccount,
|
||||
to_addrs: list[str],
|
||||
cc_addrs: list[str] = None,
|
||||
bcc_addrs: list[str] = None,
|
||||
subject: str = "",
|
||||
body_html: str = "",
|
||||
body_text: str = "",
|
||||
in_reply_to: str | None = None,
|
||||
references_header: str | None = None,
|
||||
signature: MailSignature | None = None,
|
||||
) -> dict:
|
||||
"""Send an email via SMTP using aiosmtplib."""
|
||||
cc_addrs = cc_addrs or []
|
||||
bcc_addrs = bcc_addrs or []
|
||||
|
||||
# Apply signature if provided
|
||||
if signature and signature.body_html:
|
||||
body_html = body_html + f"<br><br>{signature.body_html}"
|
||||
if body_text:
|
||||
body_text = body_text + "\n\n-- \n" + _strip_html(signature.body_html)
|
||||
|
||||
# Build email message
|
||||
msg = EmailMessage()
|
||||
msg["From"] = formataddr((account.display_name or "", account.email_address))
|
||||
msg["To"] = ", ".join(to_addrs)
|
||||
if cc_addrs:
|
||||
msg["Cc"] = ", ".join(cc_addrs)
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg_id = make_msgid(
|
||||
domain=account.email_address.split("@")[-1] if "@" in account.email_address else "localhost"
|
||||
)
|
||||
msg["Message-ID"] = msg_id
|
||||
if in_reply_to:
|
||||
msg["In-Reply-To"] = in_reply_to
|
||||
if references_header:
|
||||
msg["References"] = references_header
|
||||
|
||||
if body_html:
|
||||
msg.set_content(body_text or _strip_html(body_html), subtype="plain")
|
||||
msg.add_alternative(body_html, subtype="html")
|
||||
else:
|
||||
msg.set_content(body_text, subtype="plain")
|
||||
|
||||
# Send via SMTP
|
||||
password = await get_account_password(account)
|
||||
try:
|
||||
smtp = aiosmtplib.SMTP(
|
||||
hostname=account.smtp_host,
|
||||
port=account.smtp_port,
|
||||
use_tls=account.smtp_tls,
|
||||
)
|
||||
await smtp.connect()
|
||||
await smtp.login(account.username, password)
|
||||
recipients = to_addrs + cc_addrs + bcc_addrs
|
||||
await smtp.send_message(msg, recipients=recipients)
|
||||
await smtp.quit()
|
||||
|
||||
# Store sent mail in Sent folder
|
||||
sent_folder = (
|
||||
await db.execute(
|
||||
select(MailFolder).where(
|
||||
and_(
|
||||
MailFolder.account_id == account.id,
|
||||
MailFolder.imap_name == "Sent",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if sent_folder:
|
||||
thread_id = _compute_thread_id(msg_id, references_header or "", in_reply_to)
|
||||
sent_mail = Mail(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
folder_id=sent_folder.id,
|
||||
message_id=msg_id,
|
||||
thread_id=thread_id,
|
||||
in_reply_to=in_reply_to,
|
||||
references_header=references_header,
|
||||
subject=subject,
|
||||
from_address=account.email_address,
|
||||
to_addresses=", ".join(to_addrs),
|
||||
cc_addresses=", ".join(cc_addrs),
|
||||
bcc_addresses=", ".join(bcc_addrs),
|
||||
body_text=body_text or _strip_html(body_html),
|
||||
body_html=body_html,
|
||||
body_html_sanitized=sanitize_html(body_html),
|
||||
is_seen=True,
|
||||
is_answered=bool(in_reply_to),
|
||||
sent_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(sent_mail)
|
||||
await db.flush()
|
||||
|
||||
return {"status": "sent", "message_id": msg_id}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
async def reply_to_mail(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
original_mail: Mail,
|
||||
account: MailAccount,
|
||||
body_html: str,
|
||||
body_text: str = "",
|
||||
reply_to_all: bool = False,
|
||||
signature: MailSignature | None = None,
|
||||
) -> dict:
|
||||
"""Reply to a mail, setting In-Reply-To and References headers (F-MAIL-02)."""
|
||||
to_addrs = [original_mail.from_address]
|
||||
if reply_to_all and original_mail.cc_addresses:
|
||||
to_addrs.extend([a.strip() for a in original_mail.cc_addresses.split(",") if a.strip()])
|
||||
|
||||
refs = original_mail.references_header or ""
|
||||
new_refs = f"{refs} {original_mail.message_id}".strip()
|
||||
|
||||
result = await send_mail_via_smtp(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
account=account,
|
||||
to_addrs=to_addrs,
|
||||
subject=f"Re: {original_mail.subject}".replace("Re: Re: ", "Re: "),
|
||||
body_html=body_html,
|
||||
body_text=body_text,
|
||||
in_reply_to=original_mail.message_id,
|
||||
references_header=new_refs,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
# Mark original as answered
|
||||
original_mail.is_answered = True
|
||||
await db.flush()
|
||||
return result
|
||||
|
||||
|
||||
async def forward_mail(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
original_mail: Mail,
|
||||
account: MailAccount,
|
||||
to_addrs: list[str],
|
||||
cc_addrs: list[str] = None,
|
||||
body_html: str = "",
|
||||
body_text: str = "",
|
||||
signature: MailSignature | None = None,
|
||||
) -> dict:
|
||||
"""Forward a mail with original as forwarded content (F-MAIL-02)."""
|
||||
fwd_subject = f"Fwd: {original_mail.subject}".replace("Fwd: Fwd: ", "Fwd: ")
|
||||
fwd_body = (
|
||||
f"<br><br>----- Original Message -----<br>"
|
||||
f"From: {original_mail.from_address}<br>"
|
||||
f"Subject: {original_mail.subject}<br><br>"
|
||||
f"{original_mail.body_html or original_mail.body_text}"
|
||||
)
|
||||
full_html = body_html + fwd_body
|
||||
full_text = (body_text or _strip_html(body_html)) + "\n\n----- Original Message -----\n"
|
||||
|
||||
result = await send_mail_via_smtp(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
account=account,
|
||||
to_addrs=to_addrs,
|
||||
cc_addrs=cc_addrs or [],
|
||||
subject=fwd_subject,
|
||||
body_html=full_html,
|
||||
body_text=full_text,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
original_mail.is_forwarded = True
|
||||
await db.flush()
|
||||
return result
|
||||
|
||||
|
||||
# ─── Template Service (F-MAIL-06) ───
|
||||
|
||||
|
||||
def substitute_template_vars(template_body: str, variables: dict[str, str]) -> str:
|
||||
"""Replace {{placeholder}} variables in template body."""
|
||||
result = template_body
|
||||
for key, value in variables.items():
|
||||
result = result.replace(f"{{{{{key}}}}}", value)
|
||||
result = result.replace(f"{{{{{key.lower()}}}}}", value)
|
||||
result = result.replace(f"{{{{{key.upper()}}}}}", value)
|
||||
return result
|
||||
|
||||
|
||||
# ─── Mail Rule Engine (F-MAIL-07) ───
|
||||
|
||||
|
||||
def matches_condition(mail: Mail, conditions: dict) -> bool:
|
||||
"""Check if a mail matches all rule conditions."""
|
||||
for field, expected in conditions.items():
|
||||
if field == "from_contains":
|
||||
if expected.lower() not in mail.from_address.lower():
|
||||
return False
|
||||
elif field == "subject_contains":
|
||||
if expected.lower() not in mail.subject.lower():
|
||||
return False
|
||||
elif field == "to_contains":
|
||||
if expected.lower() not in mail.to_addresses.lower():
|
||||
return False
|
||||
elif field == "body_contains":
|
||||
body = (mail.body_text + mail.body_html).lower()
|
||||
if expected.lower() not in body:
|
||||
return False
|
||||
elif field == "has_attachments":
|
||||
if mail.has_attachments != bool(expected):
|
||||
return False
|
||||
elif field == "is_flagged":
|
||||
if mail.is_flagged != bool(expected):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def execute_rule_actions(
|
||||
db: AsyncSession, mail: Mail, actions: dict, tenant_id: uuid.UUID
|
||||
) -> dict:
|
||||
"""Execute rule actions on a matching mail."""
|
||||
results = {}
|
||||
for action, value in actions.items():
|
||||
if action == "move_to_folder":
|
||||
folder_id = uuid.UUID(value) if isinstance(value, str) else value
|
||||
folder = (
|
||||
await db.execute(
|
||||
select(MailFolder).where(
|
||||
and_(MailFolder.id == folder_id, MailFolder.tenant_id == tenant_id)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if folder:
|
||||
mail.folder_id = folder.id
|
||||
results["moved"] = str(folder.id)
|
||||
elif action == "label":
|
||||
label_id = uuid.UUID(value) if isinstance(value, str) else value
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(MailLabelAssignment).where(
|
||||
and_(
|
||||
MailLabelAssignment.mail_id == mail.id,
|
||||
MailLabelAssignment.label_id == label_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not existing:
|
||||
assignment = MailLabelAssignment(
|
||||
tenant_id=tenant_id,
|
||||
mail_id=mail.id,
|
||||
label_id=label_id,
|
||||
)
|
||||
db.add(assignment)
|
||||
results["labeled"] = str(label_id)
|
||||
elif action == "mark_seen":
|
||||
mail.is_seen = bool(value)
|
||||
results["seen"] = bool(value)
|
||||
elif action == "mark_flagged":
|
||||
mail.is_flagged = bool(value)
|
||||
results["flagged"] = bool(value)
|
||||
elif action == "forward_to":
|
||||
results["forward_to"] = value
|
||||
await db.flush()
|
||||
return results
|
||||
|
||||
|
||||
async def apply_rules_to_mail(db: AsyncSession, mail: Mail, tenant_id: uuid.UUID) -> list[dict]:
|
||||
"""Find and apply all matching rules to a mail, sorted by priority."""
|
||||
rules = (
|
||||
(
|
||||
await db.execute(
|
||||
select(MailRule)
|
||||
.where(
|
||||
and_(
|
||||
MailRule.tenant_id == tenant_id,
|
||||
MailRule.is_active,
|
||||
or_(
|
||||
MailRule.account_id == mail.account_id,
|
||||
MailRule.account_id.is_(None),
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(MailRule.priority)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
applied = []
|
||||
for rule in rules:
|
||||
conditions = json.loads(rule.conditions) if rule.conditions else {}
|
||||
actions = json.loads(rule.actions) if rule.actions else {}
|
||||
if matches_condition(mail, conditions):
|
||||
result = await execute_rule_actions(db, mail, actions, tenant_id)
|
||||
applied.append({"rule_id": str(rule.id), "rule_name": rule.name, "actions": result})
|
||||
return applied
|
||||
|
||||
|
||||
# ─── Vacation Auto-Reply (F-MAIL-08) ───
|
||||
|
||||
|
||||
VACATION_DEDUP_HOURS = 24
|
||||
|
||||
|
||||
async def should_send_vacation_reply(
|
||||
db: AsyncSession,
|
||||
account_id: uuid.UUID,
|
||||
sender_address: str,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Check if vacation auto-reply should be sent (dedup within 24h)."""
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=VACATION_DEDUP_HOURS)
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(VacationSentLog).where(
|
||||
and_(
|
||||
VacationSentLog.account_id == account_id,
|
||||
VacationSentLog.sender_address == sender_address,
|
||||
VacationSentLog.sent_at >= cutoff,
|
||||
VacationSentLog.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return existing is None
|
||||
|
||||
|
||||
async def log_vacation_sent(
|
||||
db: AsyncSession,
|
||||
account_id: uuid.UUID,
|
||||
sender_address: str,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Log that a vacation auto-reply was sent to a sender."""
|
||||
log = VacationSentLog(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
sender_address=sender_address,
|
||||
sent_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(log)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ─── PGP Service (F-MAIL-12) ───
|
||||
|
||||
|
||||
def import_pgp_private_key(private_key_armored: str, passphrase: str = "") -> tuple[str, str]:
|
||||
"""Import a PGP private key. Returns (key_id, public_key_armored)."""
|
||||
key, _ = pgpy.PGPKey.from_blob(private_key_armored)
|
||||
if key.is_protected:
|
||||
with key.unlock(passphrase):
|
||||
pub_key = key.pubkey
|
||||
key_id = str(key.fingerprint).upper()[-16:]
|
||||
return key_id, str(pub_key)
|
||||
pub_key = key.pubkey
|
||||
key_id = str(key.fingerprint).upper()[-16:]
|
||||
return key_id, str(pub_key)
|
||||
|
||||
|
||||
def import_pgp_public_key(public_key_armored: str) -> str:
|
||||
"""Import a PGP public key. Returns key_id."""
|
||||
key, _ = pgpy.PGPKey.from_blob(public_key_armored)
|
||||
return str(key.fingerprint).upper()[-16:]
|
||||
|
||||
|
||||
def pgp_encrypt_message(plaintext: str, recipient_public_key_armored: str) -> str:
|
||||
"""Encrypt a message with recipient's public PGP key."""
|
||||
pub_key, _ = pgpy.PGPKey.from_blob(recipient_public_key_armored)
|
||||
msg = pgpy.PGPMessage.new(plaintext)
|
||||
encrypted = pub_key.encrypt(msg)
|
||||
return str(encrypted)
|
||||
|
||||
|
||||
def pgp_decrypt_message(ciphertext: str, private_key_armored: str, passphrase: str = "") -> str:
|
||||
"""Decrypt a PGP-encrypted message."""
|
||||
key, _ = pgpy.PGPKey.from_blob(private_key_armored)
|
||||
enc_msg = pgpy.PGPMessage.from_blob(ciphertext)
|
||||
if key.is_protected:
|
||||
with key.unlock(passphrase):
|
||||
decrypted = key.decrypt(enc_msg)
|
||||
return decrypted.message.decode("utf-8")
|
||||
decrypted = key.decrypt(enc_msg)
|
||||
return decrypted.message.decode("utf-8")
|
||||
|
||||
|
||||
# ─── Contact Linking (F-MAIL-10) ───
|
||||
|
||||
|
||||
def extract_email_addresses(text: str) -> list[str]:
|
||||
"""Extract email addresses from a text string."""
|
||||
if not text:
|
||||
return []
|
||||
return re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", text)
|
||||
|
||||
|
||||
# ─── Utility ───
|
||||
|
||||
|
||||
def _strip_html(html: str) -> str:
|
||||
"""Simple HTML to text conversion for plain text fallback."""
|
||||
if not html:
|
||||
return ""
|
||||
# Remove tags
|
||||
text = re.sub(r"<[^>]+>", "", html)
|
||||
# Replace HTML entities
|
||||
text = (
|
||||
text.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", '"')
|
||||
)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def mail_to_response(
|
||||
mail: Mail,
|
||||
attachments: list[MailAttachment] | None = None,
|
||||
labels: list[MailLabel] | None = None,
|
||||
) -> dict:
|
||||
"""Convert a Mail ORM object to a response dict."""
|
||||
resp = {
|
||||
"id": str(mail.id),
|
||||
"account_id": str(mail.account_id),
|
||||
"folder_id": str(mail.folder_id),
|
||||
"message_id": mail.message_id,
|
||||
"thread_id": mail.thread_id,
|
||||
"in_reply_to": mail.in_reply_to,
|
||||
"subject": mail.subject,
|
||||
"from_address": mail.from_address,
|
||||
"to_addresses": mail.to_addresses,
|
||||
"cc_addresses": mail.cc_addresses,
|
||||
"bcc_addresses": mail.bcc_addresses,
|
||||
"body_text": mail.body_text,
|
||||
"body_html_sanitized": mail.body_html_sanitized,
|
||||
"is_seen": mail.is_seen,
|
||||
"is_flagged": mail.is_flagged,
|
||||
"is_draft": mail.is_draft,
|
||||
"is_answered": mail.is_answered,
|
||||
"is_forwarded": mail.is_forwarded,
|
||||
"has_attachments": mail.has_attachments,
|
||||
"size_bytes": mail.size_bytes,
|
||||
"received_at": mail.received_at,
|
||||
"sent_at": mail.sent_at,
|
||||
"contact_id": str(mail.contact_id) if mail.contact_id else None,
|
||||
"company_id": str(mail.company_id) if mail.company_id else None,
|
||||
"attachments": [],
|
||||
"labels": [],
|
||||
}
|
||||
if attachments:
|
||||
resp["attachments"] = [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"filename": a.filename,
|
||||
"mime_type": a.mime_type,
|
||||
"size_bytes": a.size_bytes,
|
||||
"dms_file_id": str(a.dms_file_id) if a.dms_file_id else None,
|
||||
}
|
||||
for a in attachments
|
||||
]
|
||||
if labels:
|
||||
resp["labels"] = [
|
||||
{"id": str(lbl.id), "name": lbl.name, "color": lbl.color} for lbl in labels
|
||||
]
|
||||
return resp
|
||||
|
||||
|
||||
def folder_to_response(folder: MailFolder) -> dict:
|
||||
"""Convert MailFolder to response dict."""
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"account_id": str(folder.account_id),
|
||||
"name": folder.name,
|
||||
"imap_name": folder.imap_name,
|
||||
"parent_id": str(folder.parent_id) if folder.parent_id else None,
|
||||
"is_standard": folder.is_standard,
|
||||
"unread_count": folder.unread_count,
|
||||
"total_count": folder.total_count,
|
||||
}
|
||||
|
||||
|
||||
def rule_to_response(rule: MailRule) -> dict:
|
||||
"""Convert MailRule to response dict."""
|
||||
return {
|
||||
"id": str(rule.id),
|
||||
"name": rule.name,
|
||||
"account_id": str(rule.account_id) if rule.account_id else None,
|
||||
"priority": rule.priority,
|
||||
"is_active": rule.is_active,
|
||||
"conditions": json.loads(rule.conditions) if rule.conditions else {},
|
||||
"actions": json.loads(rule.actions) if rule.actions else {},
|
||||
}
|
||||
|
||||
|
||||
def template_to_response(template: MailTemplate) -> dict:
|
||||
"""Convert MailTemplate to response dict."""
|
||||
return {
|
||||
"id": str(template.id),
|
||||
"name": template.name,
|
||||
"subject": template.subject,
|
||||
"body_html": template.body_html,
|
||||
}
|
||||
|
||||
|
||||
def signature_to_response(sig: MailSignature) -> dict:
|
||||
"""Convert MailSignature to response dict."""
|
||||
return {
|
||||
"id": str(sig.id),
|
||||
"name": sig.name,
|
||||
"body_html": sig.body_html,
|
||||
"account_id": str(sig.account_id) if sig.account_id else None,
|
||||
"is_default": sig.is_default,
|
||||
}
|
||||
|
||||
|
||||
def label_to_response(label: MailLabel) -> dict:
|
||||
"""Convert MailLabel to response dict."""
|
||||
return {
|
||||
"id": str(label.id),
|
||||
"name": label.name,
|
||||
"color": label.color,
|
||||
}
|
||||
Reference in New Issue
Block a user