vbox funzionanti

This commit is contained in:
2026-03-19 11:41:10 +01:00
parent 538d6a6bec
commit b7f7c1f7c0
32 changed files with 6043 additions and 262 deletions
+2
View File
@@ -7,3 +7,5 @@ from app.models.archival import ArchivalBatch, ArchivalBatchMessage, ArchivalDip
from app.models.audit_log import AuditLog # noqa: F401
from app.models.label import Label, MessageLabel # noqa: F401
from app.models.permission import MailboxPermission # noqa: F401
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
+1
View File
@@ -61,6 +61,7 @@ class Mailbox(Base):
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
sent_last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+211
View File
@@ -0,0 +1,211 @@
"""
Modelli Notifiche Multi-canale.
Struttura:
NotificationChannel → configurazione canale (Webhook, Email, Telegram, WhatsApp)
NotificationRule → regola evento → canale
NotificationLog → log tentativi di invio (con retry e circuit breaker)
Canali supportati:
webhook POST HMAC-SHA256
email SMTP
telegram Bot API
whatsapp Meta Cloud API v18
Sicurezza:
config_enc configurazione sensibile cifrata AES-256-GCM
"""
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
ChannelType = Enum(
"webhook", "email", "telegram", "whatsapp",
name="notification_channel_type",
create_type=False,
)
NotificationStatus = Enum(
"pending", "sent", "failed", "skipped",
name="notification_status",
create_type=False,
)
class NotificationChannel(Base):
"""
Canale di notifica configurato da un tenant.
Il campo config_enc contiene la configurazione sensibile (API key,
token, SMTP password…) cifrata AES-256-GCM a livello applicativo.
"""
__tablename__ = "notification_channels"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
channel_type: Mapped[str] = mapped_column(ChannelType, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Configurazione NON sensibile (JSON libero: url, chat_id, from_email…)
config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Configurazione sensibile cifrata (token, password…) base64(GCM(json))
config_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
# Circuit breaker
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
circuit_open_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
rules: Mapped[list["NotificationRule"]] = relationship(
"NotificationRule", back_populates="channel", cascade="all, delete-orphan"
)
logs: Mapped[list["NotificationLog"]] = relationship(
"NotificationLog", back_populates="channel", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_notif_channel_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return f"<NotificationChannel {self.name!r} type={self.channel_type!r}>"
class NotificationRule(Base):
"""
Regola: evento PecFlow → canale di notifica.
event_type: new_message | state_changed | anomaly | send_failed | ...
filter: JSONB con condizioni opzionali (mailbox_id, state, ecc.)
"""
__tablename__ = "notification_rules"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
channel_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_channels.id", ondelete="CASCADE"),
nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
filter: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
channel: Mapped["NotificationChannel"] = relationship(
"NotificationChannel", back_populates="rules"
)
__table_args__ = (
Index("idx_notif_rule_tenant", "tenant_id"),
Index("idx_notif_rule_channel", "channel_id"),
Index("idx_notif_rule_event", "event_type"),
)
def __repr__(self) -> str:
return f"<NotificationRule {self.name!r} event={self.event_type!r}>"
class NotificationLog(Base):
"""
Log di ogni tentativo di notifica.
Retry indipendente per canale: max 3 tentativi, backoff 5m → 30m → 2h.
"""
__tablename__ = "notification_log"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
channel_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_channels.id", ondelete="CASCADE"),
nullable=False,
)
rule_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_rules.id", ondelete="SET NULL"),
nullable=True,
)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
event_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
status: Mapped[str] = mapped_column(NotificationStatus, nullable=False, default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
next_retry_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
channel: Mapped["NotificationChannel"] = relationship(
"NotificationChannel", back_populates="logs"
)
__table_args__ = (
Index("idx_notif_log_tenant", "tenant_id"),
Index("idx_notif_log_channel", "channel_id"),
Index("idx_notif_log_status", "status", "next_retry_at"),
)
def __repr__(self) -> str:
return f"<NotificationLog channel={self.channel_id} status={self.status!r}>"
+182
View File
@@ -0,0 +1,182 @@
"""
Modelli Virtual Box filtri nominati assegnabili agli utenti.
Struttura:
VirtualBox → definisce il filtro (nome, label, mailbox scope)
VirtualBoxRule → singola regola (field + pattern) dentro una VBox
VirtualBoxAssignment → assegnazione VBox → User
Logica:
- Le regole nella stessa VBox si combinano in AND.
- Più VBox assegnate allo stesso utente si uniscono in OR.
- Il filtro si applica automaticamente a inbox e ricerca.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
String,
Table,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
# ─── Tabella di associazione VirtualBox ↔ Mailbox ─────────────────────────────
virtual_box_mailboxes = Table(
"virtual_box_mailboxes",
Base.metadata,
Column(
"virtual_box_id",
UUID(as_uuid=True),
ForeignKey("virtual_boxes.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"mailbox_id",
UUID(as_uuid=True),
ForeignKey("mailboxes.id", ondelete="CASCADE"),
primary_key=True,
),
)
class VirtualBox(Base):
"""Casella virtuale: contenitore di regole + etichetta opzionale."""
__tablename__ = "virtual_boxes"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
label: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
rules: Mapped[list["VirtualBoxRule"]] = relationship(
"VirtualBoxRule", back_populates="virtual_box", cascade="all, delete-orphan",
order_by="VirtualBoxRule.created_at"
)
assignments: Mapped[list["VirtualBoxAssignment"]] = relationship(
"VirtualBoxAssignment", back_populates="virtual_box", cascade="all, delete-orphan"
)
mailboxes: Mapped[list["Mailbox"]] = relationship( # noqa: F821
"Mailbox",
secondary=virtual_box_mailboxes,
lazy="selectin",
)
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_vbox_name_tenant"),
Index("idx_vbox_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return f"<VirtualBox {self.name!r}>"
class VirtualBoxRule(Base):
"""
Singola regola di filtro all'interno di una VBox.
field : mailbox_id | imap_folder | subject | from_address | to_address
operator : contains | equals | starts_with | ends_with | regex
value : valore da confrontare
Può anche specificare un date_from / date_to per filtrare per periodo.
"""
__tablename__ = "virtual_box_rules"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
virtual_box_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("virtual_boxes.id", ondelete="CASCADE"),
nullable=False,
)
field: Mapped[str] = mapped_column(String(50), nullable=False)
operator: Mapped[str] = mapped_column(String(20), nullable=False, default="contains")
value: Mapped[str] = mapped_column(Text, nullable=False)
date_from: Mapped[str | None] = mapped_column(String(20), nullable=True) # ISO date
date_to: Mapped[str | None] = mapped_column(String(20), nullable=True) # ISO date
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
virtual_box: Mapped["VirtualBox"] = relationship(
"VirtualBox", back_populates="rules"
)
__table_args__ = (
Index("idx_vbox_rule_vbox", "virtual_box_id"),
)
def __repr__(self) -> str:
return f"<VirtualBoxRule {self.field!r} {self.operator!r} {self.value!r}>"
class VirtualBoxAssignment(Base):
"""Assegnazione di una VBox a un utente."""
__tablename__ = "virtual_box_assignments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
virtual_box_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("virtual_boxes.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
assigned_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
assigned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
virtual_box: Mapped["VirtualBox"] = relationship(
"VirtualBox", back_populates="assignments"
)
__table_args__ = (
UniqueConstraint("virtual_box_id", "user_id", name="uq_vbox_assignment"),
Index("idx_vbox_assign_user", "user_id"),
Index("idx_vbox_assign_vbox", "virtual_box_id"),
)
def __repr__(self) -> str:
return f"<VirtualBoxAssignment vbox={self.virtual_box_id} user={self.user_id}>"