diff --git a/GapAnalysis.md b/GapAnalysis.md
index 02268bc..a4bc308 100644
--- a/GapAnalysis.md
+++ b/GapAnalysis.md
@@ -40,18 +40,8 @@ Gestione caselle, utenti, permessi, Virtual Box, notifiche, impostazioni
Pagina Multi-tenant (Super Admin)
Tag/etichette con colori su messaggi
Virtual Box con regole e assegnazioni utenti
-COSA MANCA – PRIORITA' ALTA
-1. Dispatch automatico notifiche (Sistema di notifiche incompleto al 60%)
-Il CRUD canali/regole/log e' implementato, ma manca tutto il lato dispatch
-Non esiste worker/app/jobs/dispatch_notification.py
-NotificationService non ha il metodo evaluate_rules(event_type, message) che valuta le regole e accoda i job
-L'IMAP sync (sync.py) non chiama nulla al salvataggio di un nuovo messaggio
-Il test canale webhook e email e' uno stub che restituisce sempre successo (solo Telegram ha invio reale)
-La cifratura in notification_service.py usa base64 grezzo, non AES-256-GCM: i segreti (bot_token, webhook_secret, smtp_password) sono leggibili in chiaro nel DB
-Canale WhatsApp: nessuna implementazione reale (stub completo)
-Canale Email SMTP: nessuna implementazione reale (stub completo)
-Risultato pratico: le notifiche sono configurabili ma non vengono mai inviate automaticamente
+COSA MANCA – PRIORITA' ALTA
3. Archiviazione Sostitutiva (Fase 6 – ~15% implementata)
diff --git a/backend/alembic/versions/0010_add_templates.py b/backend/alembic/versions/0010_add_templates.py
new file mode 100644
index 0000000..de196bd
--- /dev/null
+++ b/backend/alembic/versions/0010_add_templates.py
@@ -0,0 +1,55 @@
+"""
+Migrazione 0010: tabella message_templates (Feature 1 – Template messaggi).
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision = "0010"
+down_revision = "0009"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "message_templates",
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
+ sa.Column(
+ "tenant_id",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("tenants.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("description", sa.Text, nullable=True),
+ sa.Column("subject", sa.Text, nullable=False, server_default=""),
+ sa.Column("body_text", sa.Text, nullable=True),
+ sa.Column("body_html", sa.Text, nullable=True),
+ sa.Column(
+ "created_by",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("users.id", ondelete="SET NULL"),
+ nullable=True,
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.UniqueConstraint("tenant_id", "name", name="uq_template_name_tenant"),
+ )
+ op.create_index("idx_templates_tenant", "message_templates", ["tenant_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("idx_templates_tenant", table_name="message_templates")
+ op.drop_table("message_templates")
diff --git a/backend/alembic/versions/0011_add_routing_rules.py b/backend/alembic/versions/0011_add_routing_rules.py
new file mode 100644
index 0000000..4274296
--- /dev/null
+++ b/backend/alembic/versions/0011_add_routing_rules.py
@@ -0,0 +1,110 @@
+"""
+Migrazione 0011: tabelle routing_rules, routing_rule_conditions, routing_rule_actions
+(Feature 2 – Regole di smistamento automatico).
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision = "0011"
+down_revision = "0010"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Tabella principale regole
+ op.create_table(
+ "routing_rules",
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
+ sa.Column(
+ "tenant_id",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("tenants.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("description", sa.Text, nullable=True),
+ sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
+ sa.Column("priority", sa.Integer, nullable=False, server_default="100"),
+ sa.Column("stop_processing", sa.Boolean, nullable=False, server_default="true"),
+ sa.Column(
+ "created_by",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("users.id", ondelete="SET NULL"),
+ nullable=True,
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ )
+ op.create_index("idx_routing_rules_tenant", "routing_rules", ["tenant_id"])
+ op.create_index(
+ "idx_routing_rules_active",
+ "routing_rules",
+ ["tenant_id", "priority"],
+ postgresql_where=sa.text("is_active = true"),
+ )
+
+ # Condizioni delle regole
+ op.create_table(
+ "routing_rule_conditions",
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
+ sa.Column(
+ "rule_id",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("routing_rules.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ # field: from_address | to_address | subject | mailbox_id | pec_type
+ sa.Column("field", sa.String(50), nullable=False),
+ # operator: contains | equals | starts_with | ends_with | regex | not_contains
+ sa.Column("operator", sa.String(30), nullable=False, server_default="contains"),
+ sa.Column("value", sa.Text, nullable=False),
+ )
+ op.create_index(
+ "idx_routing_conditions_rule",
+ "routing_rule_conditions",
+ ["rule_id"],
+ )
+
+ # Azioni delle regole
+ op.create_table(
+ "routing_rule_actions",
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
+ sa.Column(
+ "rule_id",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("routing_rules.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ # action_type: apply_label | assign_vbox | mark_read | mark_starred | notify_webhook
+ sa.Column("action_type", sa.String(50), nullable=False),
+ # action_value: UUID di label/vbox, URL del webhook, ecc.
+ sa.Column("action_value", sa.Text, nullable=True),
+ )
+ op.create_index(
+ "idx_routing_actions_rule",
+ "routing_rule_actions",
+ ["rule_id"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("idx_routing_actions_rule", table_name="routing_rule_actions")
+ op.drop_table("routing_rule_actions")
+ op.drop_index("idx_routing_conditions_rule", table_name="routing_rule_conditions")
+ op.drop_table("routing_rule_conditions")
+ op.drop_index("idx_routing_rules_active", table_name="routing_rules")
+ op.drop_index("idx_routing_rules_tenant", table_name="routing_rules")
+ op.drop_table("routing_rules")
diff --git a/backend/alembic/versions/0012_add_deadline.py b/backend/alembic/versions/0012_add_deadline.py
new file mode 100644
index 0000000..6a01d9d
--- /dev/null
+++ b/backend/alembic/versions/0012_add_deadline.py
@@ -0,0 +1,35 @@
+"""
+Migrazione 0012: campi deadline su tabella messages
+(Feature 4 – Scadenzario e tracking deadlines).
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "0012"
+down_revision = "0011"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "messages",
+ sa.Column("deadline_at", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.add_column(
+ "messages",
+ sa.Column("deadline_note", sa.Text, nullable=True),
+ )
+ op.create_index(
+ "idx_messages_deadline",
+ "messages",
+ ["tenant_id", "deadline_at"],
+ postgresql_where=sa.text("deadline_at IS NOT NULL"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("idx_messages_deadline", table_name="messages")
+ op.drop_column("messages", "deadline_note")
+ op.drop_column("messages", "deadline_at")
diff --git a/backend/alembic/versions/0013_add_scheduled_send.py b/backend/alembic/versions/0013_add_scheduled_send.py
new file mode 100644
index 0000000..c0ba2b2
--- /dev/null
+++ b/backend/alembic/versions/0013_add_scheduled_send.py
@@ -0,0 +1,30 @@
+"""
+Migrazione 0013: campo scheduled_at su send_jobs
+(Feature 5 – Invio differito).
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "0013"
+down_revision = "0012"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "send_jobs",
+ sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.create_index(
+ "idx_sendjobs_scheduled",
+ "send_jobs",
+ ["scheduled_at"],
+ postgresql_where=sa.text("status = 'pending' AND scheduled_at IS NOT NULL"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("idx_sendjobs_scheduled", table_name="send_jobs")
+ op.drop_column("send_jobs", "scheduled_at")
diff --git a/backend/alembic/versions/0014_add_pec_contacts.py b/backend/alembic/versions/0014_add_pec_contacts.py
new file mode 100644
index 0000000..fcdb3bc
--- /dev/null
+++ b/backend/alembic/versions/0014_add_pec_contacts.py
@@ -0,0 +1,60 @@
+"""
+Migrazione 0014: tabella pec_contacts (Feature 6 – Rubrica indirizzi PEC).
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision = "0014"
+down_revision = "0013"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "pec_contacts",
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
+ sa.Column(
+ "tenant_id",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("tenants.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("email", sa.String(255), nullable=False),
+ sa.Column("name", sa.String(255), nullable=True),
+ sa.Column("organization", sa.String(255), nullable=True),
+ sa.Column("notes", sa.Text, nullable=True),
+ sa.Column("is_favorite", sa.Boolean, nullable=False, server_default="false"),
+ # auto_saved=True: contatto creato automaticamente dal sistema durante la sync
+ # auto_saved=False: contatto aggiunto manualmente dall'utente
+ sa.Column("auto_saved", sa.Boolean, nullable=False, server_default="false"),
+ sa.Column(
+ "created_by",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("users.id", ondelete="SET NULL"),
+ nullable=True,
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.UniqueConstraint("tenant_id", "email", name="uq_pec_contact_email_tenant"),
+ )
+ op.create_index("idx_pec_contacts_tenant", "pec_contacts", ["tenant_id"])
+ op.create_index("idx_pec_contacts_email", "pec_contacts", ["tenant_id", "email"])
+
+
+def downgrade() -> None:
+ op.drop_index("idx_pec_contacts_email", table_name="pec_contacts")
+ op.drop_index("idx_pec_contacts_tenant", table_name="pec_contacts")
+ op.drop_table("pec_contacts")
diff --git a/backend/app/api/v1/contacts.py b/backend/app/api/v1/contacts.py
new file mode 100644
index 0000000..cdb3de5
--- /dev/null
+++ b/backend/app/api/v1/contacts.py
@@ -0,0 +1,132 @@
+"""
+Router rubrica indirizzi PEC (Feature 6).
+
+Endpoint:
+ GET /contacts – lista contatti (con ricerca)
+ POST /contacts – crea contatto manuale
+ GET /contacts/autocomplete – autocomplete per compose
+ GET /contacts/{id} – dettaglio contatto
+ PUT /contacts/{id} – aggiorna contatto
+ DELETE /contacts/{id} – elimina contatto
+ POST /contacts/import – importa da CSV
+"""
+
+import uuid
+
+from fastapi import APIRouter, Query, UploadFile, File, status
+
+from app.dependencies import CurrentUser, DB
+from app.schemas.pec_contact import (
+ PecContactCreate,
+ PecContactImportResult,
+ PecContactListResponse,
+ PecContactResponse,
+ PecContactUpdate,
+)
+from app.services.pec_contact_service import PecContactService
+
+router = APIRouter(tags=["Contacts"])
+
+
+@router.get("/contacts", response_model=PecContactListResponse)
+async def list_contacts(
+ current_user: CurrentUser,
+ db: DB,
+ q: str | None = Query(None, description="Ricerca per email, nome o organizzazione"),
+ page: int = Query(1, ge=1),
+ page_size: int = Query(50, ge=1, le=200),
+) -> PecContactListResponse:
+ """Elenca i contatti della rubrica PEC del tenant."""
+ svc = PecContactService(db)
+ items, total = await svc.list_contacts(
+ current_user.tenant_id, q=q, page=page, page_size=page_size
+ )
+ return PecContactListResponse(
+ items=[PecContactResponse.model_validate(c) for c in items],
+ total=total,
+ )
+
+
+@router.get("/contacts/autocomplete", response_model=list[PecContactResponse])
+async def autocomplete_contacts(
+ q: str,
+ current_user: CurrentUser,
+ db: DB,
+ limit: int = Query(10, ge=1, le=20),
+) -> list[PecContactResponse]:
+ """Ricerca rapida contatti per autocomplete nel compose (minimo 2 caratteri)."""
+ svc = PecContactService(db)
+ items = await svc.search_for_autocomplete(current_user.tenant_id, q=q, limit=limit)
+ return [PecContactResponse.model_validate(c) for c in items]
+
+
+@router.post("/contacts", response_model=PecContactResponse, status_code=status.HTTP_201_CREATED)
+async def create_contact(
+ data: PecContactCreate,
+ current_user: CurrentUser,
+ db: DB,
+) -> PecContactResponse:
+ """Aggiunge un contatto manualmente alla rubrica."""
+ svc = PecContactService(db)
+ contact = await svc.create_contact(
+ current_user.tenant_id, data, created_by=current_user.id
+ )
+ return PecContactResponse.model_validate(contact)
+
+
+@router.get("/contacts/{contact_id}", response_model=PecContactResponse)
+async def get_contact(
+ contact_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> PecContactResponse:
+ """Restituisce il dettaglio di un contatto."""
+ svc = PecContactService(db)
+ contact = await svc.get_contact(current_user.tenant_id, contact_id)
+ return PecContactResponse.model_validate(contact)
+
+
+@router.put("/contacts/{contact_id}", response_model=PecContactResponse)
+async def update_contact(
+ contact_id: uuid.UUID,
+ data: PecContactUpdate,
+ current_user: CurrentUser,
+ db: DB,
+) -> PecContactResponse:
+ """Aggiorna un contatto della rubrica."""
+ svc = PecContactService(db)
+ contact = await svc.update_contact(current_user.tenant_id, contact_id, data)
+ return PecContactResponse.model_validate(contact)
+
+
+@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_contact(
+ contact_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> None:
+ """Elimina un contatto dalla rubrica."""
+ svc = PecContactService(db)
+ await svc.delete_contact(current_user.tenant_id, contact_id)
+
+
+@router.post("/contacts/import", response_model=PecContactImportResult)
+async def import_contacts_csv(
+ file: UploadFile = File(..., description="File CSV con colonne: email, name, organization"),
+ current_user: CurrentUser = ..., # type: ignore
+ db: DB = ..., # type: ignore
+) -> PecContactImportResult:
+ """
+ Importa contatti dalla rubrica da file CSV.
+
+ Il CSV deve avere le intestazioni: email, name, organization
+ (solo email e' obbligatoria).
+ """
+ content = await file.read()
+ try:
+ csv_text = content.decode("utf-8")
+ except UnicodeDecodeError:
+ csv_text = content.decode("latin-1")
+
+ svc = PecContactService(db)
+ return await svc.import_csv(current_user.tenant_id, csv_text, created_by=current_user.id)
diff --git a/backend/app/api/v1/deadlines.py b/backend/app/api/v1/deadlines.py
new file mode 100644
index 0000000..7740cd3
--- /dev/null
+++ b/backend/app/api/v1/deadlines.py
@@ -0,0 +1,152 @@
+"""
+Router scadenzario e tracking deadlines (Feature 4).
+
+Endpoint:
+ GET /deadlines – messaggi con scadenze imminenti
+ POST /messages/{id}/deadline – imposta/modifica/rimuove scadenza
+"""
+
+import uuid
+from datetime import datetime, timedelta, timezone
+
+from fastapi import APIRouter, Query
+from pydantic import BaseModel
+from sqlalchemy import and_, select
+
+from app.dependencies import CurrentUser, DB
+from app.models.message import Message
+from app.schemas.message import MessageResponse
+from app.core.exceptions import NotFoundError
+
+router = APIRouter(tags=["Deadlines"])
+
+
+class DeadlineSetRequest(BaseModel):
+ deadline_at: datetime | None = None
+ """Imposta a null per rimuovere la scadenza."""
+ deadline_note: str | None = None
+
+
+class DeadlineMessageResponse(BaseModel):
+ model_config = {"from_attributes": True}
+
+ id: uuid.UUID
+ subject: str | None
+ from_address: str | None
+ to_addresses: list[str] | None = None
+ direction: str
+ pec_type: str
+ state: str
+ mailbox_id: uuid.UUID
+ deadline_at: datetime | None = None
+ deadline_note: str | None = None
+ is_overdue: bool = False
+ received_at: datetime | None = None
+ sent_at: datetime | None = None
+ created_at: datetime
+
+
+@router.get("/deadlines", response_model=list[DeadlineMessageResponse])
+async def list_deadlines(
+ current_user: CurrentUser,
+ db: DB,
+ days_ahead: int = Query(30, ge=1, le=365, description="Giorni da considerare in avanti"),
+ include_overdue: bool = Query(True, description="Includi scadenze gia' passate"),
+) -> list[DeadlineMessageResponse]:
+ """
+ Restituisce i messaggi con scadenze nel range specificato.
+
+ Ordinati per: scaduti prima, poi per deadline_at ASC.
+ """
+ now = datetime.now(timezone.utc)
+ future_limit = now + timedelta(days=days_ahead)
+
+ conditions = [
+ Message.tenant_id == current_user.tenant_id,
+ Message.deadline_at.is_not(None),
+ Message.is_trashed == False, # noqa: E712
+ ]
+
+ if include_overdue:
+ # Include scaduti e futuri fino al limite
+ conditions.append(Message.deadline_at <= future_limit)
+ else:
+ # Solo scadenze future
+ conditions.append(and_(Message.deadline_at > now, Message.deadline_at <= future_limit))
+
+ result = await db.execute(
+ select(Message)
+ .where(and_(*conditions))
+ .order_by(Message.deadline_at)
+ .limit(200)
+ )
+ messages = list(result.scalars().all())
+
+ items = []
+ for msg in messages:
+ is_overdue = msg.deadline_at < now if msg.deadline_at else False
+ items.append(DeadlineMessageResponse(
+ id=msg.id,
+ subject=msg.subject,
+ from_address=msg.from_address,
+ to_addresses=msg.to_addresses,
+ direction=msg.direction,
+ pec_type=msg.pec_type,
+ state=msg.state,
+ mailbox_id=msg.mailbox_id,
+ deadline_at=msg.deadline_at,
+ deadline_note=msg.deadline_note,
+ is_overdue=is_overdue,
+ received_at=msg.received_at,
+ sent_at=msg.sent_at,
+ created_at=msg.created_at,
+ ))
+ return items
+
+
+@router.post("/messages/{message_id}/deadline", response_model=DeadlineMessageResponse)
+async def set_deadline(
+ message_id: uuid.UUID,
+ data: DeadlineSetRequest,
+ current_user: CurrentUser,
+ db: DB,
+) -> DeadlineMessageResponse:
+ """
+ Imposta, modifica o rimuove la scadenza di un messaggio.
+
+ Passa deadline_at=null per rimuovere la scadenza.
+ """
+ result = await db.execute(
+ select(Message).where(
+ Message.id == message_id,
+ Message.tenant_id == current_user.tenant_id,
+ )
+ )
+ msg = result.scalar_one_or_none()
+ if not msg:
+ raise NotFoundError(f"Messaggio {message_id} non trovato")
+
+ msg.deadline_at = data.deadline_at
+ msg.deadline_note = data.deadline_note
+ await db.commit()
+ await db.refresh(msg)
+
+ now = datetime.now(timezone.utc)
+ is_overdue = msg.deadline_at < now if msg.deadline_at else False
+
+ return DeadlineMessageResponse(
+ id=msg.id,
+ subject=msg.subject,
+ from_address=msg.from_address,
+ to_addresses=msg.to_addresses,
+ direction=msg.direction,
+ pec_type=msg.pec_type,
+ state=msg.state,
+ mailbox_id=msg.mailbox_id,
+ deadline_at=msg.deadline_at,
+ deadline_note=msg.deadline_note,
+ is_overdue=is_overdue,
+ received_at=msg.received_at,
+ sent_at=msg.sent_at,
+ created_at=msg.created_at,
+ )
diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py
index 24cfa1f..407c2e3 100644
--- a/backend/app/api/v1/messages.py
+++ b/backend/app/api/v1/messages.py
@@ -709,3 +709,284 @@ async def list_receipts(
)
receipts = list(result.scalars().all())
return [MessageResponse.model_validate(r) for r in receipts]
+
+
+# ─── Feature 3: Thread/conversazioni ─────────────────────────────────────────
+
+@router.get("/{message_id}/thread", response_model=list[MessageResponse])
+async def get_thread(
+ message_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> list[MessageResponse]:
+ """
+ Restituisce l'intera conversazione (thread) di cui fa parte il messaggio.
+
+ Risale alla radice della conversazione (risalendo i parent_message_id),
+ poi carica tutti i messaggi del thread ordinati cronologicamente.
+ Esclude le ricevute PEC (pec_type != posta_certificata).
+ """
+ message = await _resolve_message(message_id, current_user, db)
+
+ # Risale alla radice del thread
+ root_id = message.id
+ visited: set[uuid.UUID] = {message.id}
+ current = message
+ while current.parent_message_id and current.parent_message_id not in visited:
+ visited.add(current.parent_message_id)
+ parent_result = await db.execute(
+ select(Message).where(
+ Message.id == current.parent_message_id,
+ Message.tenant_id == current_user.tenant_id,
+ )
+ )
+ parent = parent_result.scalar_one_or_none()
+ if not parent:
+ break
+ current = parent
+ root_id = current.id
+
+ # Carica ricorsivamente tutti i messaggi del thread dalla radice
+ # Limita a posta_certificata (esclude accettazioni, consegne, ecc.)
+ thread_messages: list[Message] = []
+
+ async def _collect(msg_id: uuid.UUID) -> None:
+ result = await db.execute(
+ select(Message)
+ .where(
+ Message.id == msg_id,
+ Message.tenant_id == current_user.tenant_id,
+ Message.pec_type == "posta_certificata",
+ )
+ .options(selectinload(Message.labels))
+ )
+ msg = result.scalar_one_or_none()
+ if msg:
+ thread_messages.append(msg)
+
+ children_result = await db.execute(
+ select(Message)
+ .where(
+ Message.parent_message_id == msg_id,
+ Message.tenant_id == current_user.tenant_id,
+ Message.pec_type == "posta_certificata",
+ )
+ .options(selectinload(Message.labels))
+ .order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
+ )
+ children = list(children_result.scalars().all())
+ for child in children:
+ await _collect(child.id)
+
+ await _collect(root_id)
+
+ # Ordina cronologicamente
+ thread_messages.sort(
+ key=lambda m: m.received_at or m.sent_at or m.created_at
+ )
+
+ return [MessageResponse.model_validate(m) for m in thread_messages]
+
+
+# ─── Feature 7: Preview allegati (presigned URL) ──────────────────────────────
+
+@router.get("/{message_id}/attachments/{attachment_id}/preview-url")
+async def get_attachment_preview_url(
+ message_id: uuid.UUID,
+ attachment_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> dict:
+ """
+ Restituisce una presigned URL MinIO per la preview inline dell'allegato.
+
+ La URL e' valida per 5 minuti. Supporta PDF e immagini.
+ Per altri tipi di file reindirizza al download normale.
+ """
+ await _resolve_message(message_id, current_user, db)
+
+ result = await db.execute(
+ select(Attachment).where(
+ Attachment.id == attachment_id,
+ Attachment.message_id == message_id,
+ )
+ )
+ attachment = result.scalar_one_or_none()
+ if not attachment:
+ raise NotFoundError(f"Allegato {attachment_id} non trovato")
+
+ content_type = attachment.content_type or "application/octet-stream"
+ previewable = (
+ content_type.startswith("image/") or
+ content_type == "application/pdf"
+ )
+
+ if not previewable:
+ return {
+ "previewable": False,
+ "content_type": content_type,
+ "filename": attachment.filename,
+ }
+
+ try:
+ from datetime import timedelta as _timedelta
+ from miniopy_async import Minio
+
+ client = Minio(
+ endpoint=settings.minio_endpoint,
+ access_key=settings.minio_access_key,
+ secret_key=settings.minio_secret_key,
+ secure=settings.minio_use_ssl,
+ )
+ presigned_url = await client.presigned_get_object(
+ settings.minio_bucket,
+ attachment.storage_path,
+ expires=_timedelta(minutes=5),
+ )
+ return {
+ "previewable": True,
+ "content_type": content_type,
+ "filename": attachment.filename,
+ "url": presigned_url,
+ }
+ except Exception as e:
+ from app.core.logging import get_logger
+ logger = get_logger(__name__)
+ logger.error(f"Errore generazione presigned URL allegato {attachment_id}: {e}")
+ return {
+ "previewable": False,
+ "content_type": content_type,
+ "filename": attachment.filename,
+ }
+
+
+# ─── Feature 8: Stampa/export HTML ────────────────────────────────────────────
+
+@router.get("/{message_id}/print")
+async def print_message(
+ message_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> "HTMLResponse":
+ """
+ Restituisce una rappresentazione HTML ottimizzata per la stampa del messaggio.
+
+ Include: intestazione, corpo, lista allegati, albero ricevute.
+ Pronto per window.print() o salvataggio come PDF tramite browser.
+ """
+ from fastapi.responses import HTMLResponse
+
+ message = await _resolve_message(message_id, current_user, db)
+
+ att_result = await db.execute(
+ select(Attachment).where(Attachment.message_id == message.id).order_by(Attachment.created_at)
+ )
+ attachments = list(att_result.scalars().all())
+
+ receipts_html = ""
+ if message.direction == "outbound":
+ rec_result = await db.execute(
+ select(Message)
+ .where(Message.parent_message_id == message.id)
+ .order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
+ )
+ receipts = list(rec_result.scalars().all())
+
+ PEC_TYPE_LABELS = {
+ "accettazione": "Accettazione",
+ "avvenuta_consegna": "Avvenuta consegna",
+ "non_accettazione": "Non accettazione",
+ "mancata_consegna": "Mancata consegna",
+ "errore_consegna": "Errore consegna",
+ "presa_in_carico": "Presa in carico",
+ "preavviso_mancata_consegna": "Preavviso mancata consegna",
+ "rilevazione_virus": "Rilevazione virus",
+ }
+
+ receipt_rows = ""
+ for r in receipts:
+ label = PEC_TYPE_LABELS.get(r.pec_type, r.pec_type)
+ date_str = ""
+ if r.received_at:
+ date_str = r.received_at.strftime("%d/%m/%Y %H:%M:%S")
+ receipt_rows += f"
| {label} | {date_str} |
"
+
+ if receipt_rows:
+ receipts_html = f"""
+
+ Tracciamento invio
+
+ | Tipo ricevuta | Data |
+ {receipt_rows}
+
+ """
+
+ att_rows = ""
+ for att in attachments:
+ size_str = f"{att.size_bytes:,} byte" if att.size_bytes else ""
+ att_rows += f"{att.filename} ({att.content_type or ''}) {size_str}"
+
+ att_html = f"Allegati ({len(attachments)})
" if attachments else ""
+
+ from_label = "Da" if message.direction == "inbound" else "A"
+ from_val = message.from_address if message.direction == "inbound" else ", ".join(message.to_addresses or [])
+ date_val = ""
+ date_field = message.received_at or message.sent_at or message.created_at
+ if date_field:
+ date_val = date_field.strftime("%d/%m/%Y %H:%M:%S")
+
+ body_html = ""
+ if message.body_html:
+ body_html = f"{message.body_html}
"
+ elif message.body_text:
+ body_html = f"{message.body_text}"
+
+ deadline_html = ""
+ if message.deadline_at:
+ dl_str = message.deadline_at.strftime("%d/%m/%Y %H:%M")
+ deadline_html = f"Scadenza: {dl_str}
"
+ if message.deadline_note:
+ deadline_html += f"Nota scadenza: {message.deadline_note}
"
+
+ html = f"""
+
+
+
+PEC - {message.subject or '(nessun oggetto)'}
+
+
+
+{message.subject or '(nessun oggetto)'}
+
+{deadline_html}
+{body_html}
+{att_html}
+{receipts_html}
+
+ Documento generato da PEChub il {date_val} – ID messaggio: {message.id}
+
+
+"""
+
+ return HTMLResponse(content=html, media_type="text/html; charset=utf-8")
diff --git a/backend/app/api/v1/routing_rules.py b/backend/app/api/v1/routing_rules.py
new file mode 100644
index 0000000..f0473f7
--- /dev/null
+++ b/backend/app/api/v1/routing_rules.py
@@ -0,0 +1,106 @@
+"""
+Router regole di smistamento automatico (Feature 2).
+
+Endpoint:
+ GET /routing-rules – lista regole del tenant
+ POST /routing-rules – crea regola (admin)
+ GET /routing-rules/{id} – dettaglio regola
+ PUT /routing-rules/{id} – aggiorna regola (admin)
+ DELETE /routing-rules/{id} – elimina regola (admin)
+ POST /routing-rules/{id}/toggle – abilita/disabilita regola (admin)
+"""
+
+import uuid
+
+from fastapi import APIRouter, status
+
+from app.dependencies import AdminUser, CurrentUser, DB
+from app.schemas.routing_rule import (
+ RoutingRuleCreate,
+ RoutingRuleListResponse,
+ RoutingRuleResponse,
+ RoutingRuleUpdate,
+)
+from app.services.routing_rule_service import RoutingRuleService
+
+router = APIRouter(tags=["Routing Rules"])
+
+
+@router.get("/routing-rules", response_model=RoutingRuleListResponse)
+async def list_rules(
+ current_user: CurrentUser,
+ db: DB,
+) -> RoutingRuleListResponse:
+ """Elenca le regole di smistamento del tenant."""
+ svc = RoutingRuleService(db)
+ items, total = await svc.list_rules(current_user.tenant_id)
+ return RoutingRuleListResponse(
+ items=[RoutingRuleResponse.model_validate(r) for r in items],
+ total=total,
+ )
+
+
+@router.post("/routing-rules", response_model=RoutingRuleResponse, status_code=status.HTTP_201_CREATED)
+async def create_rule(
+ data: RoutingRuleCreate,
+ current_user: AdminUser,
+ db: DB,
+) -> RoutingRuleResponse:
+ """Crea una nuova regola di smistamento (solo admin)."""
+ svc = RoutingRuleService(db)
+ rule = await svc.create_rule(current_user.tenant_id, data, created_by=current_user.id)
+ return RoutingRuleResponse.model_validate(rule)
+
+
+@router.get("/routing-rules/{rule_id}", response_model=RoutingRuleResponse)
+async def get_rule(
+ rule_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> RoutingRuleResponse:
+ """Restituisce il dettaglio di una regola."""
+ svc = RoutingRuleService(db)
+ rule = await svc.get_rule(current_user.tenant_id, rule_id)
+ return RoutingRuleResponse.model_validate(rule)
+
+
+@router.put("/routing-rules/{rule_id}", response_model=RoutingRuleResponse)
+async def update_rule(
+ rule_id: uuid.UUID,
+ data: RoutingRuleUpdate,
+ current_user: AdminUser,
+ db: DB,
+) -> RoutingRuleResponse:
+ """Aggiorna una regola di smistamento (solo admin)."""
+ svc = RoutingRuleService(db)
+ rule = await svc.update_rule(current_user.tenant_id, rule_id, data)
+ return RoutingRuleResponse.model_validate(rule)
+
+
+@router.delete("/routing-rules/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_rule(
+ rule_id: uuid.UUID,
+ current_user: AdminUser,
+ db: DB,
+) -> None:
+ """Elimina una regola di smistamento (solo admin)."""
+ svc = RoutingRuleService(db)
+ await svc.delete_rule(current_user.tenant_id, rule_id)
+
+
+@router.post("/routing-rules/{rule_id}/toggle", response_model=RoutingRuleResponse)
+async def toggle_rule(
+ rule_id: uuid.UUID,
+ current_user: AdminUser,
+ db: DB,
+) -> RoutingRuleResponse:
+ """Abilita o disabilita una regola di smistamento (admin)."""
+ svc = RoutingRuleService(db)
+ rule = await svc.get_rule(current_user.tenant_id, rule_id)
+ from app.schemas.routing_rule import RoutingRuleUpdate
+ updated = await svc.update_rule(
+ current_user.tenant_id,
+ rule_id,
+ RoutingRuleUpdate(is_active=not rule.is_active),
+ )
+ return RoutingRuleResponse.model_validate(updated)
diff --git a/backend/app/api/v1/templates.py b/backend/app/api/v1/templates.py
new file mode 100644
index 0000000..ffd38c5
--- /dev/null
+++ b/backend/app/api/v1/templates.py
@@ -0,0 +1,83 @@
+"""
+Router template messaggi (Feature 1).
+
+Endpoint:
+ GET /templates – lista template del tenant
+ POST /templates – crea template (admin)
+ GET /templates/{id} – dettaglio template
+ PUT /templates/{id} – aggiorna template (admin)
+ DELETE /templates/{id} – elimina template (admin)
+"""
+
+import uuid
+
+from fastapi import APIRouter, Query, status
+
+from app.dependencies import AdminUser, CurrentUser, DB
+from app.schemas.template import TemplateCreate, TemplateListResponse, TemplateResponse, TemplateUpdate
+from app.services.template_service import TemplateService
+
+router = APIRouter(tags=["Templates"])
+
+
+@router.get("/templates", response_model=TemplateListResponse)
+async def list_templates(
+ current_user: CurrentUser,
+ db: DB,
+ q: str | None = Query(None, description="Filtro per nome"),
+) -> TemplateListResponse:
+ """Elenca i template del tenant corrente."""
+ svc = TemplateService(db)
+ items, total = await svc.list_templates(current_user.tenant_id, q=q)
+ return TemplateListResponse(
+ items=[TemplateResponse.model_validate(t) for t in items],
+ total=total,
+ )
+
+
+@router.post("/templates", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
+async def create_template(
+ data: TemplateCreate,
+ current_user: AdminUser,
+ db: DB,
+) -> TemplateResponse:
+ """Crea un nuovo template (solo admin)."""
+ svc = TemplateService(db)
+ template = await svc.create_template(current_user.tenant_id, data, created_by=current_user.id)
+ return TemplateResponse.model_validate(template)
+
+
+@router.get("/templates/{template_id}", response_model=TemplateResponse)
+async def get_template(
+ template_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> TemplateResponse:
+ """Restituisce il dettaglio di un template."""
+ svc = TemplateService(db)
+ template = await svc.get_template(current_user.tenant_id, template_id)
+ return TemplateResponse.model_validate(template)
+
+
+@router.put("/templates/{template_id}", response_model=TemplateResponse)
+async def update_template(
+ template_id: uuid.UUID,
+ data: TemplateUpdate,
+ current_user: AdminUser,
+ db: DB,
+) -> TemplateResponse:
+ """Aggiorna un template esistente (solo admin)."""
+ svc = TemplateService(db)
+ template = await svc.update_template(current_user.tenant_id, template_id, data)
+ return TemplateResponse.model_validate(template)
+
+
+@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_template(
+ template_id: uuid.UUID,
+ current_user: AdminUser,
+ db: DB,
+) -> None:
+ """Elimina un template (solo admin)."""
+ svc = TemplateService(db)
+ await svc.delete_template(current_user.tenant_id, template_id)
diff --git a/backend/app/main.py b/backend/app/main.py
index 5c1b51e..76a45e8 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
-from app.api.v1 import audit_log, auth, labels, mailboxes, messages, notifications, permissions, reports, send, tenants, users, virtual_boxes, ws
+from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, tenants, templates, users, virtual_boxes, ws
from app.api.v1 import settings as settings_router
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
@@ -98,6 +98,10 @@ app.include_router(labels.router, prefix=API_PREFIX)
app.include_router(settings_router.router, prefix=API_PREFIX)
app.include_router(reports.router, prefix=API_PREFIX)
app.include_router(audit_log.router, prefix=API_PREFIX)
+app.include_router(templates.router, prefix=API_PREFIX)
+app.include_router(routing_rules.router, prefix=API_PREFIX)
+app.include_router(contacts.router, prefix=API_PREFIX)
+app.include_router(deadlines.router, prefix=API_PREFIX)
# ─── Health check ─────────────────────────────────────────────────────────────
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index eb7d327..3018dd9 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -10,3 +10,6 @@ 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
from app.models.tenant_settings import TenantSettings # noqa: F401
+from app.models.template import MessageTemplate # noqa: F401
+from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # noqa: F401
+from app.models.pec_contact import PecContact # noqa: F401
diff --git a/backend/app/models/message.py b/backend/app/models/message.py
index b7b756e..e3fba06 100644
--- a/backend/app/models/message.py
+++ b/backend/app/models/message.py
@@ -95,6 +95,10 @@ class Message(Base):
is_trashed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
trashed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ # Scadenzario (Feature 4)
+ deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ deadline_note: Mapped[str | None] = mapped_column(Text, nullable=True)
+
# Conservazione
is_pending_conservation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
pending_conservation_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -194,6 +198,8 @@ class SendJob(Base):
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
+ # Invio differito (Feature 5): se impostato, il job non viene processato prima di questa data
+ scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
queued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
diff --git a/backend/app/models/pec_contact.py b/backend/app/models/pec_contact.py
new file mode 100644
index 0000000..2c1bdb2
--- /dev/null
+++ b/backend/app/models/pec_contact.py
@@ -0,0 +1,49 @@
+"""
+Modello PecContact – rubrica indirizzi PEC del tenant.
+"""
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.database import Base
+
+
+class PecContact(Base):
+ __tablename__ = "pec_contacts"
+
+ 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
+ )
+ email: Mapped[str] = mapped_column(String(255), nullable=False)
+ name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ organization: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ is_favorite: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ # auto_saved=True → salvato automaticamente dalla sincronizzazione IMAP
+ # auto_saved=False → aggiunto manualmente dall'utente
+ auto_saved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ created_by: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), 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()
+ )
+
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "email", name="uq_pec_contact_email_tenant"),
+ Index("idx_pec_contacts_tenant", "tenant_id"),
+ Index("idx_pec_contacts_email", "tenant_id", "email"),
+ )
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/backend/app/models/routing_rule.py b/backend/app/models/routing_rule.py
new file mode 100644
index 0000000..e07d56b
--- /dev/null
+++ b/backend/app/models/routing_rule.py
@@ -0,0 +1,114 @@
+"""
+Modelli RoutingRule, RoutingRuleCondition, RoutingRuleAction.
+
+Le regole di smistamento automatico vengono valutate a ogni messaggio in arrivo:
+ 1. Si caricano tutte le regole attive del tenant, ordinate per priority ASC
+ 2. Per ogni regola si valutano le condizioni (AND tra le condizioni della stessa regola)
+ 3. Se tutte le condizioni sono soddisfatte, si eseguono le azioni
+ 4. Se stop_processing=True, si ferma l'elaborazione (non vengono valutate regole successive)
+"""
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.database import Base
+
+
+class RoutingRule(Base):
+ __tablename__ = "routing_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
+ )
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
+ # Se True, le regole successive non vengono valutate una volta che questa fa match
+ stop_processing: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ created_by: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), 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
+ conditions: Mapped[list["RoutingRuleCondition"]] = relationship(
+ "RoutingRuleCondition", back_populates="rule", cascade="all, delete-orphan"
+ )
+ actions: Mapped[list["RoutingRuleAction"]] = relationship(
+ "RoutingRuleAction", back_populates="rule", cascade="all, delete-orphan"
+ )
+
+ __table_args__ = (
+ Index("idx_routing_rules_tenant", "tenant_id"),
+ Index("idx_routing_rules_active", "tenant_id", "priority"),
+ )
+
+ def __repr__(self) -> str:
+ return f""
+
+
+class RoutingRuleCondition(Base):
+ """
+ Singola condizione di una regola.
+
+ field : from_address | to_address | subject | mailbox_id | pec_type
+ operator : contains | equals | starts_with | ends_with | regex | not_contains
+ value : valore da confrontare (stringa)
+ """
+
+ __tablename__ = "routing_rule_conditions"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ rule_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("routing_rules.id", ondelete="CASCADE"), nullable=False
+ )
+ field: Mapped[str] = mapped_column(String(50), nullable=False)
+ operator: Mapped[str] = mapped_column(String(30), nullable=False, default="contains")
+ value: Mapped[str] = mapped_column(Text, nullable=False)
+
+ rule: Mapped["RoutingRule"] = relationship("RoutingRule", back_populates="conditions")
+
+ __table_args__ = (
+ Index("idx_routing_conditions_rule", "rule_id"),
+ )
+
+
+class RoutingRuleAction(Base):
+ """
+ Singola azione di una regola.
+
+ action_type : apply_label | assign_vbox | mark_read | mark_starred | notify_webhook
+ action_value : UUID della label/vbox, o URL del webhook
+ """
+
+ __tablename__ = "routing_rule_actions"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ rule_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("routing_rules.id", ondelete="CASCADE"), nullable=False
+ )
+ action_type: Mapped[str] = mapped_column(String(50), nullable=False)
+ action_value: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ rule: Mapped["RoutingRule"] = relationship("RoutingRule", back_populates="actions")
+
+ __table_args__ = (
+ Index("idx_routing_actions_rule", "rule_id"),
+ )
diff --git a/backend/app/models/template.py b/backend/app/models/template.py
new file mode 100644
index 0000000..34bcc41
--- /dev/null
+++ b/backend/app/models/template.py
@@ -0,0 +1,45 @@
+"""
+Modello MessageTemplate – template riutilizzabili per la composizione PEC.
+"""
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.database import Base
+
+
+class MessageTemplate(Base):
+ __tablename__ = "message_templates"
+
+ 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)
+ subject: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
+ body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_by: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), 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()
+ )
+
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "name", name="uq_template_name_tenant"),
+ Index("idx_templates_tenant", "tenant_id"),
+ )
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/backend/app/schemas/pec_contact.py b/backend/app/schemas/pec_contact.py
new file mode 100644
index 0000000..607c195
--- /dev/null
+++ b/backend/app/schemas/pec_contact.py
@@ -0,0 +1,56 @@
+"""
+Schemi Pydantic per PecContact (Feature 6 – Rubrica indirizzi PEC).
+"""
+
+import uuid
+from datetime import datetime
+
+from pydantic import BaseModel, EmailStr, field_validator
+
+
+class PecContactCreate(BaseModel):
+ email: EmailStr
+ name: str | None = None
+ organization: str | None = None
+ notes: str | None = None
+ is_favorite: bool = False
+
+ @field_validator("email")
+ @classmethod
+ def email_lowercase(cls, v: str) -> str:
+ return v.lower().strip()
+
+
+class PecContactUpdate(BaseModel):
+ name: str | None = None
+ organization: str | None = None
+ notes: str | None = None
+ is_favorite: bool | None = None
+
+
+class PecContactResponse(BaseModel):
+ model_config = {"from_attributes": True}
+
+ id: uuid.UUID
+ tenant_id: uuid.UUID
+ email: str
+ name: str | None = None
+ organization: str | None = None
+ notes: str | None = None
+ is_favorite: bool
+ auto_saved: bool
+ created_by: uuid.UUID | None = None
+ created_at: datetime
+ updated_at: datetime
+
+
+class PecContactListResponse(BaseModel):
+ items: list[PecContactResponse]
+ total: int
+
+
+class PecContactImportResult(BaseModel):
+ created: int
+ updated: int
+ skipped: int
+ errors: list[str] = []
diff --git a/backend/app/schemas/routing_rule.py b/backend/app/schemas/routing_rule.py
new file mode 100644
index 0000000..0c82079
--- /dev/null
+++ b/backend/app/schemas/routing_rule.py
@@ -0,0 +1,113 @@
+"""
+Schemi Pydantic per RoutingRule (Feature 2 – Regole di smistamento automatico).
+"""
+
+import uuid
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, field_validator
+
+# Valori validi per field nelle condizioni
+CONDITION_FIELDS = Literal[
+ "from_address", "to_address", "subject", "mailbox_id", "pec_type"
+]
+# Operatori supportati
+CONDITION_OPERATORS = Literal[
+ "contains", "equals", "starts_with", "ends_with", "regex", "not_contains"
+]
+# Tipi di azione
+ACTION_TYPES = Literal[
+ "apply_label", "assign_vbox", "mark_read", "mark_starred", "notify_webhook"
+]
+
+
+class RoutingRuleConditionCreate(BaseModel):
+ field: CONDITION_FIELDS
+ operator: CONDITION_OPERATORS = "contains"
+ value: str
+
+ @field_validator("value")
+ @classmethod
+ def value_not_empty(cls, v: str) -> str:
+ if not v.strip():
+ raise ValueError("Il valore della condizione non puo' essere vuoto")
+ return v.strip()
+
+
+class RoutingRuleActionCreate(BaseModel):
+ action_type: ACTION_TYPES
+ action_value: str | None = None
+
+
+class RoutingRuleCreate(BaseModel):
+ name: str
+ description: str | None = None
+ is_active: bool = True
+ priority: int = 100
+ stop_processing: bool = True
+ conditions: list[RoutingRuleConditionCreate] = []
+ actions: list[RoutingRuleActionCreate] = []
+
+ @field_validator("name")
+ @classmethod
+ def name_not_empty(cls, v: str) -> str:
+ if not v.strip():
+ raise ValueError("Il nome della regola non puo' essere vuoto")
+ return v.strip()
+
+ @field_validator("priority")
+ @classmethod
+ def priority_positive(cls, v: int) -> int:
+ if v < 1:
+ raise ValueError("La priorita' deve essere >= 1")
+ return v
+
+
+class RoutingRuleUpdate(BaseModel):
+ name: str | None = None
+ description: str | None = None
+ is_active: bool | None = None
+ priority: int | None = None
+ stop_processing: bool | None = None
+ conditions: list[RoutingRuleConditionCreate] | None = None
+ actions: list[RoutingRuleActionCreate] | None = None
+
+
+class RoutingRuleConditionResponse(BaseModel):
+ model_config = {"from_attributes": True}
+
+ id: uuid.UUID
+ field: str
+ operator: str
+ value: str
+
+
+class RoutingRuleActionResponse(BaseModel):
+ model_config = {"from_attributes": True}
+
+ id: uuid.UUID
+ action_type: str
+ action_value: str | None = None
+
+
+class RoutingRuleResponse(BaseModel):
+ model_config = {"from_attributes": True}
+
+ id: uuid.UUID
+ tenant_id: uuid.UUID
+ name: str
+ description: str | None = None
+ is_active: bool
+ priority: int
+ stop_processing: bool
+ conditions: list[RoutingRuleConditionResponse] = []
+ actions: list[RoutingRuleActionResponse] = []
+ created_by: uuid.UUID | None = None
+ created_at: datetime
+ updated_at: datetime
+
+
+class RoutingRuleListResponse(BaseModel):
+ items: list[RoutingRuleResponse]
+ total: int
diff --git a/backend/app/schemas/send.py b/backend/app/schemas/send.py
index b6e022e..0cb650d 100644
--- a/backend/app/schemas/send.py
+++ b/backend/app/schemas/send.py
@@ -37,6 +37,9 @@ class SendPecRequest(BaseModel):
reply_to_message_id: uuid.UUID | None = None
"""UUID del messaggio a cui si risponde (per threading, opzionale)."""
+ scheduled_at: datetime | None = None
+ """Data/ora di invio differito (Feature 5). Se None, invio immediato."""
+
@field_validator("to_addresses")
@classmethod
def at_least_one_recipient(cls, v: list[EmailStr]) -> list[EmailStr]:
@@ -67,6 +70,7 @@ class SendJobResponse(BaseModel):
max_attempts: int
next_retry_at: datetime | None = None
last_error: str | None = None
+ scheduled_at: datetime | None = None
queued_at: datetime
sent_at: datetime | None = None
created_by: uuid.UUID | None = None
diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py
new file mode 100644
index 0000000..c04fa03
--- /dev/null
+++ b/backend/app/schemas/template.py
@@ -0,0 +1,51 @@
+"""
+Schemi Pydantic per MessageTemplate (Feature 1 – Template messaggi).
+"""
+
+import uuid
+from datetime import datetime
+
+from pydantic import BaseModel, field_validator
+
+
+class TemplateCreate(BaseModel):
+ name: str
+ description: str | None = None
+ subject: str = ""
+ body_text: str | None = None
+ body_html: str | None = None
+
+ @field_validator("name")
+ @classmethod
+ def name_not_empty(cls, v: str) -> str:
+ if not v.strip():
+ raise ValueError("Il nome del template non puo' essere vuoto")
+ return v.strip()
+
+
+class TemplateUpdate(BaseModel):
+ name: str | None = None
+ description: str | None = None
+ subject: str | None = None
+ body_text: str | None = None
+ body_html: str | None = None
+
+
+class TemplateResponse(BaseModel):
+ model_config = {"from_attributes": True}
+
+ id: uuid.UUID
+ tenant_id: uuid.UUID
+ name: str
+ description: str | None = None
+ subject: str
+ body_text: str | None = None
+ body_html: str | None = None
+ created_by: uuid.UUID | None = None
+ created_at: datetime
+ updated_at: datetime
+
+
+class TemplateListResponse(BaseModel):
+ items: list[TemplateResponse]
+ total: int
diff --git a/backend/app/services/pec_contact_service.py b/backend/app/services/pec_contact_service.py
new file mode 100644
index 0000000..de1bc57
--- /dev/null
+++ b/backend/app/services/pec_contact_service.py
@@ -0,0 +1,240 @@
+"""
+Service per la gestione della rubrica indirizzi PEC (Feature 6).
+"""
+
+import csv
+import io
+import uuid
+
+from sqlalchemy import func, or_, select
+from sqlalchemy.dialects.postgresql import insert
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.exceptions import ConflictError, NotFoundError
+from app.models.pec_contact import PecContact
+from app.schemas.pec_contact import (
+ PecContactCreate,
+ PecContactImportResult,
+ PecContactUpdate,
+)
+
+
+class PecContactService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def list_contacts(
+ self,
+ tenant_id: uuid.UUID,
+ q: str | None = None,
+ page: int = 1,
+ page_size: int = 50,
+ ) -> tuple[list[PecContact], int]:
+ query = select(PecContact).where(PecContact.tenant_id == tenant_id)
+ if q:
+ pattern = f"%{q.lower()}%"
+ query = query.where(
+ or_(
+ PecContact.email.ilike(pattern),
+ PecContact.name.ilike(pattern),
+ PecContact.organization.ilike(pattern),
+ )
+ )
+ query = query.order_by(PecContact.is_favorite.desc(), PecContact.name, PecContact.email)
+
+ count_q = select(func.count()).select_from(query.subquery())
+ total = (await self.db.execute(count_q)).scalar_one()
+
+ offset = (page - 1) * page_size
+ query = query.offset(offset).limit(page_size)
+ items = list((await self.db.execute(query)).scalars().all())
+ return items, total
+
+ async def get_contact(
+ self, tenant_id: uuid.UUID, contact_id: uuid.UUID
+ ) -> PecContact:
+ result = await self.db.execute(
+ select(PecContact).where(
+ PecContact.id == contact_id,
+ PecContact.tenant_id == tenant_id,
+ )
+ )
+ contact = result.scalar_one_or_none()
+ if not contact:
+ raise NotFoundError(f"Contatto {contact_id} non trovato")
+ return contact
+
+ async def create_contact(
+ self,
+ tenant_id: uuid.UUID,
+ data: PecContactCreate,
+ created_by: uuid.UUID | None = None,
+ ) -> PecContact:
+ email = data.email.lower().strip()
+ # Verifica unicita'
+ existing = await self.db.execute(
+ select(PecContact).where(
+ PecContact.tenant_id == tenant_id,
+ PecContact.email == email,
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise ConflictError(f"Contatto '{email}' gia' esistente")
+
+ contact = PecContact(
+ tenant_id=tenant_id,
+ email=email,
+ name=data.name,
+ organization=data.organization,
+ notes=data.notes,
+ is_favorite=data.is_favorite,
+ auto_saved=False,
+ created_by=created_by,
+ )
+ self.db.add(contact)
+ await self.db.commit()
+ await self.db.refresh(contact)
+ return contact
+
+ async def update_contact(
+ self,
+ tenant_id: uuid.UUID,
+ contact_id: uuid.UUID,
+ data: PecContactUpdate,
+ ) -> PecContact:
+ contact = await self.get_contact(tenant_id, contact_id)
+ if data.name is not None:
+ contact.name = data.name
+ if data.organization is not None:
+ contact.organization = data.organization
+ if data.notes is not None:
+ contact.notes = data.notes
+ if data.is_favorite is not None:
+ contact.is_favorite = data.is_favorite
+ await self.db.commit()
+ await self.db.refresh(contact)
+ return contact
+
+ async def delete_contact(
+ self, tenant_id: uuid.UUID, contact_id: uuid.UUID
+ ) -> None:
+ contact = await self.get_contact(tenant_id, contact_id)
+ await self.db.delete(contact)
+ await self.db.commit()
+
+ async def auto_save_sender(
+ self,
+ tenant_id: uuid.UUID,
+ email: str,
+ ) -> None:
+ """
+ Salva automaticamente il mittente nella rubrica se non esiste ancora.
+ Operazione non bloccante: gli errori vengono ignorati silenziosamente.
+
+ Usata dal worker IMAP durante la sincronizzazione dei messaggi inbound.
+ """
+ if not email:
+ return
+ email = email.lower().strip()
+ try:
+ # Upsert: inserisce solo se non esiste
+ stmt = insert(PecContact).values(
+ id=uuid.uuid4(),
+ tenant_id=tenant_id,
+ email=email,
+ auto_saved=True,
+ ).on_conflict_do_nothing(
+ constraint="uq_pec_contact_email_tenant"
+ )
+ await self.db.execute(stmt)
+ await self.db.commit()
+ except Exception:
+ await self.db.rollback()
+
+ async def import_csv(
+ self,
+ tenant_id: uuid.UUID,
+ csv_content: str,
+ created_by: uuid.UUID | None = None,
+ ) -> PecContactImportResult:
+ """
+ Importa contatti da un CSV con colonne: email, name, organization.
+
+ Aggiorna i record esistenti con name/organization se forniti.
+ """
+ result = PecContactImportResult(created=0, updated=0, skipped=0)
+ reader = csv.DictReader(io.StringIO(csv_content))
+
+ for row_num, row in enumerate(reader, start=2):
+ email = row.get("email", "").strip().lower()
+ if not email or "@" not in email:
+ result.skipped += 1
+ result.errors.append(f"Riga {row_num}: email non valida '{email}'")
+ continue
+
+ name = row.get("name", "").strip() or None
+ organization = row.get("organization", "").strip() or None
+
+ try:
+ existing = await self.db.execute(
+ select(PecContact).where(
+ PecContact.tenant_id == tenant_id,
+ PecContact.email == email,
+ )
+ )
+ contact = existing.scalar_one_or_none()
+
+ if contact:
+ # Aggiorna solo se i campi erano vuoti (non sovrascrive dati manuali)
+ updated = False
+ if name and not contact.name:
+ contact.name = name
+ updated = True
+ if organization and not contact.organization:
+ contact.organization = organization
+ updated = True
+ if updated:
+ result.updated += 1
+ else:
+ result.skipped += 1
+ else:
+ contact = PecContact(
+ tenant_id=tenant_id,
+ email=email,
+ name=name,
+ organization=organization,
+ auto_saved=False,
+ created_by=created_by,
+ )
+ self.db.add(contact)
+ result.created += 1
+ except Exception as e:
+ result.errors.append(f"Riga {row_num} ({email}): {e}")
+ result.skipped += 1
+
+ await self.db.commit()
+ return result
+
+ async def search_for_autocomplete(
+ self,
+ tenant_id: uuid.UUID,
+ q: str,
+ limit: int = 10,
+ ) -> list[PecContact]:
+ """Ricerca veloce per autocomplete nel compose."""
+ if not q or len(q) < 2:
+ return []
+ pattern = f"%{q.lower()}%"
+ result = await self.db.execute(
+ select(PecContact)
+ .where(
+ PecContact.tenant_id == tenant_id,
+ or_(
+ PecContact.email.ilike(pattern),
+ PecContact.name.ilike(pattern),
+ )
+ )
+ .order_by(PecContact.is_favorite.desc(), PecContact.email)
+ .limit(limit)
+ )
+ return list(result.scalars().all())
diff --git a/backend/app/services/routing_rule_service.py b/backend/app/services/routing_rule_service.py
new file mode 100644
index 0000000..cab70a0
--- /dev/null
+++ b/backend/app/services/routing_rule_service.py
@@ -0,0 +1,296 @@
+"""
+Service per la gestione delle regole di smistamento automatico (Feature 2).
+
+Il metodo evaluate_rules() viene chiamato dal worker dopo ogni messaggio inbound.
+"""
+
+import re
+import uuid
+
+from sqlalchemy import delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.core.exceptions import NotFoundError
+from app.models.label import Label, MessageLabel
+from app.models.message import Message
+from app.models.routing_rule import RoutingRule, RoutingRuleAction, RoutingRuleCondition
+from app.schemas.routing_rule import RoutingRuleCreate, RoutingRuleUpdate
+
+
+class RoutingRuleService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ # ─── CRUD ─────────────────────────────────────────────────────────────────
+
+ async def list_rules(
+ self,
+ tenant_id: uuid.UUID,
+ ) -> tuple[list[RoutingRule], int]:
+ query = (
+ select(RoutingRule)
+ .where(RoutingRule.tenant_id == tenant_id)
+ .options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
+ .order_by(RoutingRule.priority)
+ )
+ count_q = select(func.count()).select_from(
+ select(RoutingRule).where(RoutingRule.tenant_id == tenant_id).subquery()
+ )
+ total = (await self.db.execute(count_q)).scalar_one()
+ items = list((await self.db.execute(query)).scalars().all())
+ return items, total
+
+ async def get_rule(
+ self, tenant_id: uuid.UUID, rule_id: uuid.UUID
+ ) -> RoutingRule:
+ result = await self.db.execute(
+ select(RoutingRule)
+ .where(RoutingRule.id == rule_id, RoutingRule.tenant_id == tenant_id)
+ .options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
+ )
+ rule = result.scalar_one_or_none()
+ if not rule:
+ raise NotFoundError(f"Regola {rule_id} non trovata")
+ return rule
+
+ async def create_rule(
+ self,
+ tenant_id: uuid.UUID,
+ data: RoutingRuleCreate,
+ created_by: uuid.UUID | None = None,
+ ) -> RoutingRule:
+ rule = RoutingRule(
+ tenant_id=tenant_id,
+ name=data.name,
+ description=data.description,
+ is_active=data.is_active,
+ priority=data.priority,
+ stop_processing=data.stop_processing,
+ created_by=created_by,
+ )
+ self.db.add(rule)
+ await self.db.flush()
+
+ for cond in data.conditions:
+ self.db.add(RoutingRuleCondition(
+ rule_id=rule.id,
+ field=cond.field,
+ operator=cond.operator,
+ value=cond.value,
+ ))
+ for action in data.actions:
+ self.db.add(RoutingRuleAction(
+ rule_id=rule.id,
+ action_type=action.action_type,
+ action_value=action.action_value,
+ ))
+
+ await self.db.commit()
+ return await self.get_rule(tenant_id, rule.id)
+
+ async def update_rule(
+ self,
+ tenant_id: uuid.UUID,
+ rule_id: uuid.UUID,
+ data: RoutingRuleUpdate,
+ ) -> RoutingRule:
+ rule = await self.get_rule(tenant_id, rule_id)
+
+ if data.name is not None:
+ rule.name = data.name
+ if data.description is not None:
+ rule.description = data.description
+ if data.is_active is not None:
+ rule.is_active = data.is_active
+ if data.priority is not None:
+ rule.priority = data.priority
+ if data.stop_processing is not None:
+ rule.stop_processing = data.stop_processing
+
+ # Se condizioni o azioni vengono aggiornate, le sostituisce completamente
+ if data.conditions is not None:
+ await self.db.execute(
+ delete(RoutingRuleCondition).where(RoutingRuleCondition.rule_id == rule_id)
+ )
+ for cond in data.conditions:
+ self.db.add(RoutingRuleCondition(
+ rule_id=rule_id,
+ field=cond.field,
+ operator=cond.operator,
+ value=cond.value,
+ ))
+
+ if data.actions is not None:
+ await self.db.execute(
+ delete(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule_id)
+ )
+ for action in data.actions:
+ self.db.add(RoutingRuleAction(
+ rule_id=rule_id,
+ action_type=action.action_type,
+ action_value=action.action_value,
+ ))
+
+ await self.db.commit()
+ return await self.get_rule(tenant_id, rule_id)
+
+ async def delete_rule(
+ self, tenant_id: uuid.UUID, rule_id: uuid.UUID
+ ) -> None:
+ rule = await self.get_rule(tenant_id, rule_id)
+ await self.db.delete(rule)
+ await self.db.commit()
+
+ # ─── Motore di valutazione ────────────────────────────────────────────────
+
+ async def evaluate_and_apply(
+ self,
+ message: Message,
+ ) -> int:
+ """
+ Valuta le regole attive del tenant e applica le azioni su message.
+
+ Returns:
+ Numero di regole che hanno prodotto match.
+ """
+ # Carica regole attive ordinate per priority
+ result = await self.db.execute(
+ select(RoutingRule)
+ .where(
+ RoutingRule.tenant_id == message.tenant_id,
+ RoutingRule.is_active == True, # noqa: E712
+ )
+ .options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
+ .order_by(RoutingRule.priority)
+ )
+ rules = list(result.scalars().all())
+
+ matched_count = 0
+ for rule in rules:
+ if await self._matches(message, rule.conditions):
+ matched_count += 1
+ await self._apply_actions(message, rule.actions)
+ if rule.stop_processing:
+ break
+
+ if matched_count > 0:
+ await self.db.flush()
+
+ return matched_count
+
+ async def _matches(
+ self,
+ message: Message,
+ conditions: list[RoutingRuleCondition],
+ ) -> bool:
+ """Restituisce True se tutte le condizioni (AND) sono soddisfatte."""
+ if not conditions:
+ # Una regola senza condizioni non fa mai match (comportamento sicuro)
+ return False
+
+ for cond in conditions:
+ field_value = self._get_field_value(message, cond.field)
+ if not self._evaluate_condition(field_value, cond.operator, cond.value):
+ return False
+ return True
+
+ def _get_field_value(self, message: Message, field: str) -> str:
+ """Estrae il valore del campo dal messaggio come stringa per il confronto."""
+ if field == "from_address":
+ return (message.from_address or "").lower()
+ elif field == "to_address":
+ return " ".join(message.to_addresses or []).lower()
+ elif field == "subject":
+ return (message.subject or "").lower()
+ elif field == "mailbox_id":
+ return str(message.mailbox_id)
+ elif field == "pec_type":
+ return message.pec_type or ""
+ return ""
+
+ def _evaluate_condition(
+ self, field_value: str, operator: str, value: str
+ ) -> bool:
+ v = value.lower()
+ fv = field_value.lower()
+ if operator == "contains":
+ return v in fv
+ elif operator == "not_contains":
+ return v not in fv
+ elif operator == "equals":
+ return fv == v
+ elif operator == "starts_with":
+ return fv.startswith(v)
+ elif operator == "ends_with":
+ return fv.endswith(v)
+ elif operator == "regex":
+ try:
+ return bool(re.search(value, field_value, re.IGNORECASE))
+ except re.error:
+ return False
+ return False
+
+ async def _apply_actions(
+ self,
+ message: Message,
+ actions: list[RoutingRuleAction],
+ ) -> None:
+ """Esegue le azioni sulla regola che ha fatto match."""
+ for action in actions:
+ try:
+ if action.action_type == "apply_label" and action.action_value:
+ await self._action_apply_label(message, uuid.UUID(action.action_value))
+ elif action.action_type == "mark_read":
+ message.is_read = True
+ elif action.action_type == "mark_starred":
+ message.is_starred = True
+ elif action.action_type == "notify_webhook" and action.action_value:
+ await self._action_notify_webhook(message, action.action_value)
+ except Exception:
+ # Le azioni non devono interrompere il flusso principale
+ pass
+
+ async def _action_apply_label(
+ self, message: Message, label_id: uuid.UUID
+ ) -> None:
+ """Applica un'etichetta al messaggio (se non gia' presente)."""
+ # Verifica che la label appartenga al tenant
+ label = await self.db.execute(
+ select(Label).where(
+ Label.id == label_id,
+ Label.tenant_id == message.tenant_id,
+ )
+ )
+ if not label.scalar_one_or_none():
+ return
+ # Verifica che non sia gia' applicata
+ existing = await self.db.execute(
+ select(MessageLabel).where(
+ MessageLabel.message_id == message.id,
+ MessageLabel.label_id == label_id,
+ )
+ )
+ if not existing.scalar_one_or_none():
+ self.db.add(MessageLabel(message_id=message.id, label_id=label_id))
+
+ async def _action_notify_webhook(self, message: Message, url: str) -> None:
+ """Invia una notifica webhook per il messaggio."""
+ import aiohttp
+ import json
+ payload = {
+ "event": "routing_rule_match",
+ "message_id": str(message.id),
+ "subject": message.subject,
+ "from_address": message.from_address,
+ "pec_type": message.pec_type,
+ }
+ try:
+ async with aiohttp.ClientSession() as session:
+ await session.post(
+ url,
+ json=payload,
+ timeout=aiohttp.ClientTimeout(total=5),
+ )
+ except Exception:
+ pass
diff --git a/backend/app/services/send_service.py b/backend/app/services/send_service.py
index 6945700..70bb6ba 100644
--- a/backend/app/services/send_service.py
+++ b/backend/app/services/send_service.py
@@ -166,12 +166,16 @@ class SendService:
now = datetime.now(tz=timezone.utc)
has_files = bool(attachments)
+ # Invio differito: il messaggio parte in stato 'draft' se programmato
+ scheduled_at = getattr(data, "scheduled_at", None)
+ is_scheduled = scheduled_at is not None and scheduled_at > now
+
message = Message(
tenant_id=current_user.tenant_id,
mailbox_id=data.mailbox_id,
direction="outbound",
pec_type="posta_certificata",
- state="queued",
+ state="draft" if is_scheduled else "queued",
subject=data.subject,
from_address=mailbox.email_address,
to_addresses=[str(a) for a in data.to_addresses],
@@ -211,6 +215,7 @@ class SendService:
max_attempts=5,
created_by=current_user.id,
queued_at=now,
+ scheduled_at=scheduled_at if is_scheduled else None,
)
self.db.add(job)
await self.db.flush()
@@ -218,7 +223,15 @@ class SendService:
# ── Enqueue job arq ───────────────────────────────────────────────────
try:
arq_pool = await _get_arq_pool()
- await arq_pool.enqueue_job("send_pec", str(job.id))
+ if is_scheduled and scheduled_at:
+ # Invio differito: defer_until = scheduled_at
+ await arq_pool.enqueue_job(
+ "send_pec",
+ str(job.id),
+ _defer_until=scheduled_at,
+ )
+ else:
+ await arq_pool.enqueue_job("send_pec", str(job.id))
except Exception as e:
from app.core.logging import get_logger
logger = get_logger(__name__)
diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py
new file mode 100644
index 0000000..f536887
--- /dev/null
+++ b/backend/app/services/template_service.py
@@ -0,0 +1,116 @@
+"""
+Service per la gestione dei template messaggi (Feature 1).
+"""
+
+import uuid
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.exceptions import ConflictError, NotFoundError
+from app.models.template import MessageTemplate
+from app.schemas.template import TemplateCreate, TemplateUpdate
+
+
+class TemplateService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def list_templates(
+ self,
+ tenant_id: uuid.UUID,
+ q: str | None = None,
+ ) -> tuple[list[MessageTemplate], int]:
+ query = select(MessageTemplate).where(MessageTemplate.tenant_id == tenant_id)
+ if q:
+ query = query.where(MessageTemplate.name.ilike(f"%{q}%"))
+ query = query.order_by(MessageTemplate.name)
+
+ count_q = select(func.count()).select_from(query.subquery())
+ total = (await self.db.execute(count_q)).scalar_one()
+ items = list((await self.db.execute(query)).scalars().all())
+ return items, total
+
+ async def get_template(
+ self, tenant_id: uuid.UUID, template_id: uuid.UUID
+ ) -> MessageTemplate:
+ result = await self.db.execute(
+ select(MessageTemplate).where(
+ MessageTemplate.id == template_id,
+ MessageTemplate.tenant_id == tenant_id,
+ )
+ )
+ template = result.scalar_one_or_none()
+ if not template:
+ raise NotFoundError(f"Template {template_id} non trovato")
+ return template
+
+ async def create_template(
+ self,
+ tenant_id: uuid.UUID,
+ data: TemplateCreate,
+ created_by: uuid.UUID | None = None,
+ ) -> MessageTemplate:
+ # Verifica unicita' nome
+ existing = await self.db.execute(
+ select(MessageTemplate).where(
+ MessageTemplate.tenant_id == tenant_id,
+ MessageTemplate.name == data.name,
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise ConflictError(f"Template '{data.name}' gia' esistente")
+
+ template = MessageTemplate(
+ tenant_id=tenant_id,
+ name=data.name,
+ description=data.description,
+ subject=data.subject,
+ body_text=data.body_text,
+ body_html=data.body_html,
+ created_by=created_by,
+ )
+ self.db.add(template)
+ await self.db.commit()
+ await self.db.refresh(template)
+ return template
+
+ async def update_template(
+ self,
+ tenant_id: uuid.UUID,
+ template_id: uuid.UUID,
+ data: TemplateUpdate,
+ ) -> MessageTemplate:
+ template = await self.get_template(tenant_id, template_id)
+
+ if data.name is not None:
+ existing = await self.db.execute(
+ select(MessageTemplate).where(
+ MessageTemplate.tenant_id == tenant_id,
+ MessageTemplate.name == data.name,
+ MessageTemplate.id != template_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise ConflictError(f"Template '{data.name}' gia' esistente")
+ template.name = data.name
+
+ if data.description is not None:
+ template.description = data.description
+ if data.subject is not None:
+ template.subject = data.subject
+ if data.body_text is not None:
+ template.body_text = data.body_text
+ if data.body_html is not None:
+ template.body_html = data.body_html
+
+ await self.db.commit()
+ await self.db.refresh(template)
+ return template
+
+ async def delete_template(
+ self, tenant_id: uuid.UUID, template_id: uuid.UUID
+ ) -> None:
+ template = await self.get_template(tenant_id, template_id)
+ await self.db.delete(template)
+ await self.db.commit()
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4a1ab50..2fb28d2 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -14,6 +14,10 @@ import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
import { SearchPage } from '@/pages/Search/SearchPage'
import { ReportsPage } from '@/pages/Reports/ReportsPage'
import { AuditLogPage } from '@/pages/AuditLog/AuditLogPage'
+import { TemplatesPage } from '@/pages/Templates/TemplatesPage'
+import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage'
+import { ContactsPage } from '@/pages/Contacts/ContactsPage'
+import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage'
/**
* Routing principale dell'applicazione PEChub.
@@ -92,6 +96,12 @@ export default function App() {
{/* Audit Log */}
} />
+ {/* Nuove funzionalita' */}
+ } />
+ } />
+ } />
+ } />
+
{/* Profilo utente */}
} />
diff --git a/frontend/src/api/contacts.api.ts b/frontend/src/api/contacts.api.ts
new file mode 100644
index 0000000..8409044
--- /dev/null
+++ b/frontend/src/api/contacts.api.ts
@@ -0,0 +1,65 @@
+import apiClient from './client'
+
+export interface PecContactResponse {
+ id: string
+ tenant_id: string
+ email: string
+ name: string | null
+ organization: string | null
+ notes: string | null
+ is_favorite: boolean
+ auto_saved: boolean
+ created_by: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface PecContactCreate {
+ email: string
+ name?: string | null
+ organization?: string | null
+ notes?: string | null
+ is_favorite?: boolean
+}
+
+export interface PecContactUpdate {
+ name?: string | null
+ organization?: string | null
+ notes?: string | null
+ is_favorite?: boolean
+}
+
+export interface ContactImportResult {
+ created: number
+ updated: number
+ skipped: number
+ errors: string[]
+}
+
+export const contactsApi = {
+ list: (params?: { q?: string; page?: number; page_size?: number }) =>
+ apiClient.get<{ items: PecContactResponse[]; total: number }>('/contacts', { params }).then((r) => r.data),
+
+ autocomplete: (q: string) =>
+ apiClient.get('/contacts/autocomplete', { params: { q } }).then((r) => r.data),
+
+ get: (id: string) =>
+ apiClient.get(`/contacts/${id}`).then((r) => r.data),
+
+ create: (data: PecContactCreate) =>
+ apiClient.post('/contacts', data).then((r) => r.data),
+
+ update: (id: string, data: PecContactUpdate) =>
+ apiClient.put(`/contacts/${id}`, data).then((r) => r.data),
+
+ delete: (id: string) =>
+ apiClient.delete(`/contacts/${id}`).then((r) => r.data),
+
+ importCsv: (file: File) => {
+ const formData = new FormData()
+ formData.append('file', file)
+ return apiClient.post('/contacts/import', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ }).then((r) => r.data)
+ },
+}
diff --git a/frontend/src/api/deadlines.api.ts b/frontend/src/api/deadlines.api.ts
new file mode 100644
index 0000000..39b70f7
--- /dev/null
+++ b/frontend/src/api/deadlines.api.ts
@@ -0,0 +1,31 @@
+import apiClient from './client'
+
+export interface DeadlineMessageResponse {
+ id: string
+ subject: string | null
+ from_address: string | null
+ to_addresses: string[] | null
+ direction: 'inbound' | 'outbound'
+ pec_type: string
+ state: string
+ mailbox_id: string
+ deadline_at: string | null
+ deadline_note: string | null
+ is_overdue: boolean
+ received_at: string | null
+ sent_at: string | null
+ created_at: string
+}
+
+export interface DeadlineSetRequest {
+ deadline_at: string | null
+ deadline_note?: string | null
+}
+
+export const deadlinesApi = {
+ list: (params?: { days_ahead?: number; include_overdue?: boolean }) =>
+ apiClient.get('/deadlines', { params }).then((r) => r.data),
+
+ setDeadline: (messageId: string, data: DeadlineSetRequest) =>
+ apiClient.post(`/messages/${messageId}/deadline`, data).then((r) => r.data),
+}
diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts
index 282d9d6..364244d 100644
--- a/frontend/src/api/messages.api.ts
+++ b/frontend/src/api/messages.api.ts
@@ -128,6 +128,29 @@ export const messagesApi = {
getReceipts: (id: string) =>
apiClient.get(`/messages/${id}/receipts`).then((r) => r.data),
+ // ─── Feature 3: Thread ────────────────────────────────────────────────────
+
+ getThread: (id: string) =>
+ apiClient.get(`/messages/${id}/thread`).then((r) => r.data),
+
+ // ─── Feature 7: Preview allegati ─────────────────────────────────────────
+
+ getAttachmentPreviewUrl: (messageId: string, attachmentId: string) =>
+ apiClient.get<{
+ previewable: boolean
+ content_type: string
+ filename: string
+ url?: string
+ }>(`/messages/${messageId}/attachments/${attachmentId}/preview-url`).then((r) => r.data),
+
+ // ─── Feature 8: Stampa ────────────────────────────────────────────────────
+
+ /** Apre la vista di stampa HTML in una nuova tab. */
+ openPrint: (messageId: string, token: string) => {
+ const baseUrl = (window as any).__API_BASE_URL__ || '/api/v1'
+ window.open(`${baseUrl}/messages/${messageId}/print?token=${token}`, '_blank')
+ },
+
/**
* Scarica il pacchetto ZIP completo della PEC (postacert.eml, daticert.xml,
* ricevute di accettazione/consegna per le mail outbound).
diff --git a/frontend/src/api/routing_rules.api.ts b/frontend/src/api/routing_rules.api.ts
new file mode 100644
index 0000000..d416f19
--- /dev/null
+++ b/frontend/src/api/routing_rules.api.ts
@@ -0,0 +1,65 @@
+import apiClient from './client'
+
+export type ConditionField = 'from_address' | 'to_address' | 'subject' | 'mailbox_id' | 'pec_type'
+export type ConditionOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex' | 'not_contains'
+export type ActionType = 'apply_label' | 'assign_vbox' | 'mark_read' | 'mark_starred' | 'notify_webhook'
+
+export interface RoutingRuleCondition {
+ id: string
+ field: ConditionField
+ operator: ConditionOperator
+ value: string
+}
+
+export interface RoutingRuleAction {
+ id: string
+ action_type: ActionType
+ action_value: string | null
+}
+
+export interface RoutingRuleResponse {
+ id: string
+ tenant_id: string
+ name: string
+ description: string | null
+ is_active: boolean
+ priority: number
+ stop_processing: boolean
+ conditions: RoutingRuleCondition[]
+ actions: RoutingRuleAction[]
+ created_by: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface RoutingRuleCreate {
+ name: string
+ description?: string | null
+ is_active?: boolean
+ priority?: number
+ stop_processing?: boolean
+ conditions?: Array<{ field: ConditionField; operator: ConditionOperator; value: string }>
+ actions?: Array<{ action_type: ActionType; action_value?: string | null }>
+}
+
+export type RoutingRuleUpdate = Partial
+
+export const routingRulesApi = {
+ list: () =>
+ apiClient.get<{ items: RoutingRuleResponse[]; total: number }>('/routing-rules').then((r) => r.data),
+
+ get: (id: string) =>
+ apiClient.get(`/routing-rules/${id}`).then((r) => r.data),
+
+ create: (data: RoutingRuleCreate) =>
+ apiClient.post('/routing-rules', data).then((r) => r.data),
+
+ update: (id: string, data: RoutingRuleUpdate) =>
+ apiClient.put(`/routing-rules/${id}`, data).then((r) => r.data),
+
+ delete: (id: string) =>
+ apiClient.delete(`/routing-rules/${id}`).then((r) => r.data),
+
+ toggle: (id: string) =>
+ apiClient.post(`/routing-rules/${id}/toggle`).then((r) => r.data),
+}
diff --git a/frontend/src/api/templates.api.ts b/frontend/src/api/templates.api.ts
new file mode 100644
index 0000000..30225a2
--- /dev/null
+++ b/frontend/src/api/templates.api.ts
@@ -0,0 +1,49 @@
+import apiClient from './client'
+
+export interface TemplateResponse {
+ id: string
+ tenant_id: string
+ name: string
+ description: string | null
+ subject: string
+ body_text: string | null
+ body_html: string | null
+ created_by: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface TemplateCreate {
+ name: string
+ description?: string | null
+ subject?: string
+ body_text?: string | null
+ body_html?: string | null
+}
+
+export interface TemplateUpdate {
+ name?: string
+ description?: string | null
+ subject?: string
+ body_text?: string | null
+ body_html?: string | null
+}
+
+export const templatesApi = {
+ list: (q?: string) =>
+ apiClient.get<{ items: TemplateResponse[]; total: number }>('/templates', {
+ params: { q },
+ }).then((r) => r.data),
+
+ get: (id: string) =>
+ apiClient.get(`/templates/${id}`).then((r) => r.data),
+
+ create: (data: TemplateCreate) =>
+ apiClient.post('/templates', data).then((r) => r.data),
+
+ update: (id: string, data: TemplateUpdate) =>
+ apiClient.put(`/templates/${id}`, data).then((r) => r.data),
+
+ delete: (id: string) =>
+ apiClient.delete(`/templates/${id}`).then((r) => r.data),
+}
diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx
index 6040abf..5f1c37d 100644
--- a/frontend/src/components/Layout/Sidebar.tsx
+++ b/frontend/src/components/Layout/Sidebar.tsx
@@ -54,6 +54,10 @@ import {
BarChart2,
ClipboardList,
ShieldCheck,
+ FileText,
+ Settings2,
+ BookUser,
+ Calendar,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
@@ -403,10 +407,42 @@ export function Sidebar() {
)}
- {/* ── Ricerca avanzata + Dashboard ── */}
+ {/* ── Strumenti operativi ── */}
+
+ cn(
+ 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
+ isActive
+ ? 'bg-blue-600 text-white'
+ : 'text-gray-300 hover:bg-gray-700 hover:text-white',
+ collapsed && 'justify-center px-2',
+ )
+ }
+ title={collapsed ? 'Scadenzario' : undefined}
+ >
+
+ {!collapsed && Scadenzario}
+
+
+ cn(
+ 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
+ isActive
+ ? 'bg-blue-600 text-white'
+ : 'text-gray-300 hover:bg-gray-700 hover:text-white',
+ collapsed && 'justify-center px-2',
+ )
+ }
+ title={collapsed ? 'Rubrica PEC' : undefined}
+ >
+
+ {!collapsed && Rubrica PEC}
+
@@ -518,6 +554,8 @@ export function Sidebar() {
{ to: '/users', label: 'Utenti', icon: Users },
{ to: '/permissions', label: 'Permessi', icon: Shield },
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
+ { to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 },
+ { to: '/templates', label: 'Template messaggi', icon: FileText },
{ to: '/notifications', label: 'Notifiche', icon: Bell },
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
] as const).map((item) => (
diff --git a/frontend/src/pages/Contacts/ContactsPage.tsx b/frontend/src/pages/Contacts/ContactsPage.tsx
new file mode 100644
index 0000000..c298f9a
--- /dev/null
+++ b/frontend/src/pages/Contacts/ContactsPage.tsx
@@ -0,0 +1,282 @@
+import { useState, useRef } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Plus, Search, Star, Trash2, Pencil, Upload, BookUser, Building2 } from 'lucide-react'
+import toast from 'react-hot-toast'
+import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Input'
+import { Label } from '@/components/ui/Label'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
+import { contactsApi, type PecContactResponse, type PecContactCreate, type PecContactUpdate } from '@/api/contacts.api'
+import { getErrorMessage } from '@/api/client'
+import { formatDate } from '@/lib/utils'
+
+export function ContactsPage() {
+ const queryClient = useQueryClient()
+ const [q, setQ] = useState('')
+ const [page] = useState(1)
+ const [showForm, setShowForm] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const fileInputRef = useRef(null)
+
+ // Form state
+ const [formEmail, setFormEmail] = useState('')
+ const [formName, setFormName] = useState('')
+ const [formOrg, setFormOrg] = useState('')
+ const [formNotes, setFormNotes] = useState('')
+ const [formFavorite, setFormFavorite] = useState(false)
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['contacts', q, page],
+ queryFn: () => contactsApi.list({ q: q || undefined, page, page_size: 50 }),
+ })
+
+ const createMutation = useMutation({
+ mutationFn: (d: PecContactCreate) => contactsApi.create(d),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['contacts'] })
+ toast.success('Contatto aggiunto')
+ closeForm()
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: ({ id, data }: { id: string; data: PecContactUpdate }) =>
+ contactsApi.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['contacts'] })
+ toast.success('Contatto aggiornato')
+ closeForm()
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => contactsApi.delete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['contacts'] })
+ toast.success('Contatto eliminato')
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const importMutation = useMutation({
+ mutationFn: (file: File) => contactsApi.importCsv(file),
+ onSuccess: (res) => {
+ queryClient.invalidateQueries({ queryKey: ['contacts'] })
+ toast.success(`Importati: ${res.created} nuovi, ${res.updated} aggiornati, ${res.skipped} saltati`)
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const toggleFavMutation = useMutation({
+ mutationFn: ({ id, fav }: { id: string; fav: boolean }) =>
+ contactsApi.update(id, { is_favorite: fav }),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['contacts'] }),
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const openCreate = () => {
+ setEditing(null)
+ setFormEmail('')
+ setFormName('')
+ setFormOrg('')
+ setFormNotes('')
+ setFormFavorite(false)
+ setShowForm(true)
+ }
+
+ const openEdit = (c: PecContactResponse) => {
+ setEditing(c)
+ setFormEmail(c.email)
+ setFormName(c.name ?? '')
+ setFormOrg(c.organization ?? '')
+ setFormNotes(c.notes ?? '')
+ setFormFavorite(c.is_favorite)
+ setShowForm(true)
+ }
+
+ const closeForm = () => {
+ setShowForm(false)
+ setEditing(null)
+ }
+
+ const handleSubmit = () => {
+ if (!formEmail.trim()) return toast.error('L\'email e\' obbligatoria')
+ const payload = {
+ email: formEmail.trim(),
+ name: formName.trim() || null,
+ organization: formOrg.trim() || null,
+ notes: formNotes.trim() || null,
+ is_favorite: formFavorite,
+ }
+ if (editing) {
+ updateMutation.mutate({ id: editing.id, data: payload })
+ } else {
+ createMutation.mutate(payload)
+ }
+ }
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (file) {
+ importMutation.mutate(file)
+ e.target.value = ''
+ }
+ }
+
+ const items = data?.items ?? []
+ const total = data?.total ?? 0
+
+ return (
+
+
+
+
Rubrica PEC
+
+ {total} contatti nella rubrica
+
+
+
+
+
+
+
+
+
+
+
+
+ setQ(e.target.value)}
+ className="pl-9"
+ />
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+
+
Nessun contatto trovato
+
+ Aggiungi contatti manualmente o importa un CSV con colonne: email, name, organization
+
+
+ ) : (
+
+
+
+
+ | Email PEC |
+ Nome |
+ Organizzazione |
+ Tipo |
+ Aggiornato |
+ |
+
+
+
+ {items.map((c) => (
+
+ | {c.email} |
+ {c.name ?? '-'} |
+
+ {c.organization ? (
+
+
+ {c.organization}
+
+ ) : '-'}
+ |
+
+
+ {c.auto_saved ? 'Automatico' : 'Manuale'}
+
+ |
+ {formatDate(c.updated_at)} |
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Deadlines/DeadlinesPage.tsx b/frontend/src/pages/Deadlines/DeadlinesPage.tsx
new file mode 100644
index 0000000..358550d
--- /dev/null
+++ b/frontend/src/pages/Deadlines/DeadlinesPage.tsx
@@ -0,0 +1,190 @@
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { useNavigate } from 'react-router-dom'
+import { Calendar, AlertTriangle, Clock, CheckCircle2, ExternalLink } from 'lucide-react'
+import { Button } from '@/components/ui/Button'
+import { deadlinesApi, type DeadlineMessageResponse } from '@/api/deadlines.api'
+import { formatDate } from '@/lib/utils'
+
+function groupDeadlines(items: DeadlineMessageResponse[]) {
+ const now = new Date()
+ const todayEnd = new Date(now)
+ todayEnd.setHours(23, 59, 59, 999)
+ const weekEnd = new Date(now)
+ weekEnd.setDate(weekEnd.getDate() + 7)
+ const monthEnd = new Date(now)
+ monthEnd.setDate(monthEnd.getDate() + 30)
+
+ const overdue: DeadlineMessageResponse[] = []
+ const today: DeadlineMessageResponse[] = []
+ const thisWeek: DeadlineMessageResponse[] = []
+ const later: DeadlineMessageResponse[] = []
+
+ for (const item of items) {
+ if (!item.deadline_at) continue
+ const d = new Date(item.deadline_at)
+ if (d < now) {
+ overdue.push(item)
+ } else if (d <= todayEnd) {
+ today.push(item)
+ } else if (d <= weekEnd) {
+ thisWeek.push(item)
+ } else {
+ later.push(item)
+ }
+ }
+ return { overdue, today, thisWeek, later }
+}
+
+function DeadlineItem({ item }: { item: DeadlineMessageResponse }) {
+ const navigate = useNavigate()
+ const deadlineDate = item.deadline_at ? new Date(item.deadline_at) : null
+
+ return (
+ navigate(`/messages/${item.id}`)}
+ >
+
+ {item.is_overdue ? (
+
+ ) : (
+
+ )}
+
+
+
{item.subject || '(nessun oggetto)'}
+
+ {item.direction === 'inbound' ? `Da: ${item.from_address}` : `A: ${(item.to_addresses ?? []).join(', ')}`}
+
+ {item.deadline_note && (
+
{item.deadline_note}
+ )}
+
+
+
+ {deadlineDate ? formatDate(deadlineDate.toISOString()) : '-'}
+
+ {item.is_overdue && (
+
Scaduto
+ )}
+
+
+
+ )
+}
+
+function DeadlineGroup({ title, items, icon: Icon, color }: {
+ title: string
+ items: DeadlineMessageResponse[]
+ icon: React.ComponentType<{ className?: string }>
+ color: string
+}) {
+ if (items.length === 0) return null
+ return (
+
+
+
+
{title} ({items.length})
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ )
+}
+
+export function DeadlinesPage() {
+ const [daysAhead, setDaysAhead] = useState(30)
+ const [includeOverdue, setIncludeOverdue] = useState(true)
+
+ const { data = [], isLoading } = useQuery({
+ queryKey: ['deadlines', daysAhead, includeOverdue],
+ queryFn: () => deadlinesApi.list({ days_ahead: daysAhead, include_overdue: includeOverdue }),
+ })
+
+ const groups = groupDeadlines(data)
+ const total = data.length
+
+ return (
+
+
+
+
+
+ Scadenzario
+
+
+ {total} messaggi con scadenze
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : total === 0 ? (
+
+
+
Nessuna scadenza trovata
+
+ Le scadenze si impostano dal dettaglio di ogni messaggio.
+
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx
index 663805c..170cc35 100644
--- a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx
+++ b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx
@@ -17,17 +17,132 @@ import {
RotateCcw,
MailX,
PackageOpen,
+ Printer,
+ Calendar,
+ Eye,
+ MessageSquare,
+ X,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Input'
+import { Label } from '@/components/ui/Label'
import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
import { TagBadge } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector'
import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
+import { deadlinesApi } from '@/api/deadlines.api'
import { formatDate, formatBytes } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
+import apiClient from '@/api/client'
+
+// ─── Thread section (Feature 3) ──────────────────────────────────────────────
+
+function ThreadSection({ messageId, currentId, navigate }: { messageId: string; currentId: string; navigate: (to: string) => void }) {
+ const { data: thread = [] } = useQuery({
+ queryKey: ['thread', messageId],
+ queryFn: () => messagesApi.getThread(messageId),
+ })
+
+ // Mostra solo se ci sono piu' di 1 messaggio nel thread
+ if (thread.length <= 1) return null
+
+ return (
+
+
+
+ Conversazione ({thread.length} messaggi)
+
+
+ {thread.map((msg) => {
+ const isCurrent = msg.id === currentId
+ return (
+
+ )
+ })}
+
+
+ )
+}
+
+// ─── Attachment preview modal ─────────────────────────────────────────────────
+
+interface AttachmentPreviewProps {
+ messageId: string
+ attachmentId: string
+ filename: string
+ contentType: string
+ onClose: () => void
+}
+
+function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType, onClose }: AttachmentPreviewProps) {
+ const { data, isLoading } = useQuery({
+ queryKey: ['attachment-preview', messageId, attachmentId],
+ queryFn: () => messagesApi.getAttachmentPreviewUrl(messageId, attachmentId),
+ })
+
+ return (
+
+
+
+ {filename}
+
+
+
+ {isLoading && (
+
+ )}
+ {!isLoading && data && (
+ <>
+ {data.previewable && data.url ? (
+ <>
+ {contentType.startsWith('image/') ? (
+

+ ) : contentType === 'application/pdf' ? (
+
+ ) : null}
+ >
+ ) : (
+
+
+
Anteprima non disponibile per questo tipo di file.
+
+ )}
+ >
+ )}
+
+
+
+ )
+}
+
+// ─── Pagina principale ────────────────────────────────────────────────────────
export function MessageDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -36,6 +151,15 @@ export function MessageDetailPage() {
const [showTagSelector, setShowTagSelector] = useState(false)
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
+ const [isPrinting, setIsPrinting] = useState(false)
+
+ // Feature 4: Deadline
+ const [showDeadlineForm, setShowDeadlineForm] = useState(false)
+ const [deadlineDate, setDeadlineDate] = useState('')
+ const [deadlineNote, setDeadlineNote] = useState('')
+
+ // Feature 7: Preview allegati
+ const [previewAtt, setPreviewAtt] = useState<{ id: string; filename: string; contentType: string } | null>(null)
// Carica messaggio
const {
@@ -362,6 +486,52 @@ export function MessageDetailPage() {
)}
Scarica
+
+ {/* Stampa (Feature 8) */}
+
+
+ {/* Scadenza (Feature 4) */}
+
@@ -522,25 +692,39 @@ export function MessageDetailPage() {
Allegati ({attachments.length})
- {attachments.map((att) => (
-
)}
@@ -558,6 +742,41 @@ export function MessageDetailPage() {
)}
+ {/* Scadenza (Feature 4) */}
+ {(message as any).deadline_at && (
+
+
+
+
+
+ Scadenza: {formatDate((message as any).deadline_at)}
+
+ {(message as any).deadline_note && (
+ — {(message as any).deadline_note}
+ )}
+
+
{
+ setDeadlineDate('')
+ setDeadlineNote('')
+ deadlinesApi.setDeadline(message.id, { deadline_at: null }).then(() => {
+ queryClient.invalidateQueries({ queryKey: ['message', id] })
+ toast.success('Scadenza rimossa')
+ })
+ }}
+ className="text-xs text-amber-600 hover:text-amber-800"
+ >
+ Rimuovi
+
+
+
+ )}
+
+ {/* Thread (Feature 3) */}
+ {message.pec_type === 'posta_certificata' && (
+
+ )}
+
{/* Messaggio originale per ricevute */}
{message.direction === 'inbound' && message.pec_type !== 'posta_certificata' && (
@@ -575,6 +794,63 @@ export function MessageDetailPage() {
+ {/* Modali */}
+
+ {/* Deadline form (Feature 4) */}
+ {showDeadlineForm && (
+
+
+
+
+ Imposta scadenza
+
+
+
+ setDeadlineDate(e.target.value)}
+ />
+
+
+
+ setDeadlineNote(e.target.value)}
+ placeholder="Es. Termine per impugnare, entro 30 giorni..."
+ />
+
+
+ setShowDeadlineForm(false)}>Annulla
+ {
+ await deadlinesApi.setDeadline(message.id, {
+ deadline_at: deadlineDate ? new Date(deadlineDate).toISOString() : null,
+ deadline_note: deadlineNote || null,
+ })
+ queryClient.invalidateQueries({ queryKey: ['message', id] })
+ toast.success(deadlineDate ? 'Scadenza impostata' : 'Scadenza rimossa')
+ setShowDeadlineForm(false)
+ }}
+ >
+ Salva
+
+
+
+
+ )}
+
+ {/* Attachment preview (Feature 7) */}
+ {previewAtt && (
+ setPreviewAtt(null)}
+ />
+ )}
+
{/* Dialog gestione tag */}
{showTagSelector && (
= {
+ from_address: 'Mittente',
+ to_address: 'Destinatario',
+ subject: 'Oggetto',
+ mailbox_id: 'ID Casella',
+ pec_type: 'Tipo PEC',
+}
+
+const OPERATOR_LABELS: Record = {
+ contains: 'contiene',
+ not_contains: 'non contiene',
+ equals: 'uguale a',
+ starts_with: 'inizia per',
+ ends_with: 'finisce per',
+ regex: 'regex',
+}
+
+const ACTION_LABELS: Record = {
+ apply_label: 'Applica etichetta',
+ assign_vbox: 'Assegna Virtual Box',
+ mark_read: 'Segna come letto',
+ mark_starred: 'Aggiungi ai preferiti',
+ notify_webhook: 'Notifica webhook',
+}
+
+interface Condition {
+ field: ConditionField
+ operator: ConditionOperator
+ value: string
+}
+
+interface Action {
+ action_type: ActionType
+ action_value: string
+}
+
+export function RoutingRulesPage() {
+ const queryClient = useQueryClient()
+ const [showForm, setShowForm] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [expandedRules, setExpandedRules] = useState>(new Set())
+
+ // Form state
+ const [formName, setFormName] = useState('')
+ const [formDescription, setFormDescription] = useState('')
+ const [formPriority, setFormPriority] = useState('100')
+ const [formStopProcessing, setFormStopProcessing] = useState(true)
+ const [formConditions, setFormConditions] = useState([
+ { field: 'from_address', operator: 'contains', value: '' }
+ ])
+ const [formActions, setFormActions] = useState([
+ { action_type: 'mark_read', action_value: '' }
+ ])
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['routing-rules'],
+ queryFn: () => routingRulesApi.list(),
+ })
+
+ const { data: labelsData } = useQuery({
+ queryKey: ['labels'],
+ queryFn: () => labelsApi.list(),
+ })
+ const labels = labelsData ?? []
+
+ const createMutation = useMutation({
+ mutationFn: (d: RoutingRuleCreate) => routingRulesApi.create(d),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
+ toast.success('Regola creata')
+ closeForm()
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: ({ id, data }: { id: string; data: RoutingRuleCreate }) =>
+ routingRulesApi.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
+ toast.success('Regola aggiornata')
+ closeForm()
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => routingRulesApi.delete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
+ toast.success('Regola eliminata')
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const toggleMutation = useMutation({
+ mutationFn: (id: string) => routingRulesApi.toggle(id),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-rules'] }),
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const openCreate = () => {
+ setEditing(null)
+ setFormName('')
+ setFormDescription('')
+ setFormPriority('100')
+ setFormStopProcessing(true)
+ setFormConditions([{ field: 'from_address', operator: 'contains', value: '' }])
+ setFormActions([{ action_type: 'mark_read', action_value: '' }])
+ setShowForm(true)
+ }
+
+ const openEdit = (r: RoutingRuleResponse) => {
+ setEditing(r)
+ setFormName(r.name)
+ setFormDescription(r.description ?? '')
+ setFormPriority(String(r.priority))
+ setFormStopProcessing(r.stop_processing)
+ setFormConditions(r.conditions.map(c => ({ field: c.field as ConditionField, operator: c.operator as ConditionOperator, value: c.value })))
+ setFormActions(r.actions.map(a => ({ action_type: a.action_type as ActionType, action_value: a.action_value ?? '' })))
+ setShowForm(true)
+ }
+
+ const closeForm = () => {
+ setShowForm(false)
+ setEditing(null)
+ }
+
+ const handleSubmit = () => {
+ if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
+ if (formConditions.some(c => !c.value.trim())) return toast.error('Tutte le condizioni devono avere un valore')
+
+ const payload: RoutingRuleCreate = {
+ name: formName.trim(),
+ description: formDescription.trim() || null,
+ priority: parseInt(formPriority) || 100,
+ stop_processing: formStopProcessing,
+ is_active: true,
+ conditions: formConditions.map(c => ({ field: c.field, operator: c.operator, value: c.value.trim() })),
+ actions: formActions.map(a => ({ action_type: a.action_type, action_value: a.action_value.trim() || null })),
+ }
+ if (editing) {
+ updateMutation.mutate({ id: editing.id, data: payload })
+ } else {
+ createMutation.mutate(payload)
+ }
+ }
+
+ const items = data?.items ?? []
+
+ const toggleExpand = (id: string) => {
+ setExpandedRules(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ return (
+
+
+
+
+
+ Regole di smistamento
+
+
+ Applica automaticamente etichette e azioni ai messaggi in arrivo
+
+
+
+
+ Nuova regola
+
+
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+
+
Nessuna regola configurata
+
Le regole vengono valutate in ordine di priorita' su ogni messaggio inbound.
+
+ ) : (
+ items.map((rule) => (
+
+
+
+
+ {rule.name}
+ P:{rule.priority}
+
+ {rule.is_active ? 'Attiva' : 'Inattiva'}
+
+ {rule.stop_processing && (
+ Stop
+ )}
+
+ {rule.description &&
{rule.description}
}
+
+ {rule.conditions.length} condizioni / {rule.actions.length} azioni
+
+
+
+ openEdit(rule)} title="Modifica">
+
+
+ toggleMutation.mutate(rule.id)}
+ title={rule.is_active ? 'Disattiva' : 'Attiva'}
+ >
+ {rule.is_active
+ ?
+ :
+ }
+
+ { if (confirm(`Eliminare la regola "${rule.name}"?`)) deleteMutation.mutate(rule.id) }}
+ title="Elimina"
+ >
+
+
+ toggleExpand(rule.id)}>
+ {expandedRules.has(rule.id) ? : }
+
+
+
+ {expandedRules.has(rule.id) && (
+
+
+
Condizioni (AND)
+ {rule.conditions.map((c, i) => (
+
+ {FIELD_LABELS[c.field as ConditionField] ?? c.field}
+ {OPERATOR_LABELS[c.operator as ConditionOperator] ?? c.operator}
+ {c.value}
+
+ ))}
+
+
+
Azioni
+ {rule.actions.map((a, i) => (
+
+ {ACTION_LABELS[a.action_type as ActionType] ?? a.action_type}
+ {a.action_value && {a.action_value}}
+
+ ))}
+
+
+ )}
+
+ ))
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Templates/TemplatesPage.tsx b/frontend/src/pages/Templates/TemplatesPage.tsx
new file mode 100644
index 0000000..0b05d40
--- /dev/null
+++ b/frontend/src/pages/Templates/TemplatesPage.tsx
@@ -0,0 +1,228 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Plus, Pencil, Trash2, FileText, Search } from 'lucide-react'
+import toast from 'react-hot-toast'
+import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Input'
+import { Label } from '@/components/ui/Label'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
+import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
+import { templatesApi, type TemplateResponse, type TemplateCreate } from '@/api/templates.api'
+import { getErrorMessage } from '@/api/client'
+import { formatDate } from '@/lib/utils'
+import { useAuth } from '@/hooks/useAuth'
+
+export function TemplatesPage() {
+ const queryClient = useQueryClient()
+ const { isAdmin } = useAuth()
+ const [q, setQ] = useState('')
+ const [showForm, setShowForm] = useState(false)
+ const [editing, setEditing] = useState(null)
+
+ // Form state
+ const [formName, setFormName] = useState('')
+ const [formDescription, setFormDescription] = useState('')
+ const [formSubject, setFormSubject] = useState('')
+ const [formBody, setFormBody] = useState('')
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['templates', q],
+ queryFn: () => templatesApi.list(q || undefined),
+ })
+
+ const createMutation = useMutation({
+ mutationFn: (data: TemplateCreate) => templatesApi.create(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['templates'] })
+ toast.success('Template creato')
+ closeForm()
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: ({ id, data }: { id: string; data: TemplateCreate }) =>
+ templatesApi.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['templates'] })
+ toast.success('Template aggiornato')
+ closeForm()
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => templatesApi.delete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['templates'] })
+ toast.success('Template eliminato')
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const openCreate = () => {
+ setEditing(null)
+ setFormName('')
+ setFormDescription('')
+ setFormSubject('')
+ setFormBody('')
+ setShowForm(true)
+ }
+
+ const openEdit = (t: TemplateResponse) => {
+ setEditing(t)
+ setFormName(t.name)
+ setFormDescription(t.description ?? '')
+ setFormSubject(t.subject)
+ setFormBody(t.body_html ?? t.body_text ?? '')
+ setShowForm(true)
+ }
+
+ const closeForm = () => {
+ setShowForm(false)
+ setEditing(null)
+ }
+
+ const handleSubmit = () => {
+ if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
+ const payload: TemplateCreate = {
+ name: formName.trim(),
+ description: formDescription.trim() || null,
+ subject: formSubject.trim(),
+ body_html: formBody || null,
+ body_text: null,
+ }
+ if (editing) {
+ updateMutation.mutate({ id: editing.id, data: payload })
+ } else {
+ createMutation.mutate(payload)
+ }
+ }
+
+ const items = data?.items ?? []
+
+ return (
+
+
+
+
Template messaggi
+
+ Template riutilizzabili per la composizione PEC
+
+
+ {isAdmin && (
+
+
+ Nuovo template
+
+ )}
+
+
+
+ {/* Ricerca */}
+
+
+ setQ(e.target.value)}
+ className="pl-9"
+ />
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+
+
Nessun template trovato
+
+ {isAdmin ? 'Crea il tuo primo template con il pulsante in alto.' : 'Nessun template disponibile.'}
+
+
+ ) : (
+
+ {items.map((t) => (
+
+
+
+
{t.name}
+ {t.description && (
+
{t.description}
+ )}
+
+ {isAdmin && (
+
+
openEdit(t)} className="h-8 w-8">
+
+
+
{
+ if (confirm(`Eliminare il template "${t.name}"?`)) {
+ deleteMutation.mutate(t.id)
+ }
+ }}
+ >
+
+
+
+ )}
+
+ {t.subject && (
+
+ Oggetto: {t.subject}
+
+ )}
+
+ Aggiornato: {formatDate(t.updated_at)}
+
+
+ ))}
+
+ )}
+
+
+ {/* Dialog form */}
+
+
+ )
+}
diff --git a/worker/app/imap/sync.py b/worker/app/imap/sync.py
index 2ff0f2d..868f9b6 100644
--- a/worker/app/imap/sync.py
+++ b/worker/app/imap/sync.py
@@ -659,6 +659,32 @@ async def _save_message(
redis_client=redis_client,
)
+ # ── Regole di smistamento automatico (Feature 2) ──────────────────────────
+ # Solo per messaggi inbound posta_certificata (non ricevute di sistema).
+ if direction == "inbound" and pec_class.pec_type == "posta_certificata":
+ try:
+ await redis_client.enqueue_job("apply_routing_rules", str(message.id))
+ except Exception as e:
+ logger.warning(f"[{mailbox.email_address}] Impossibile enqueue apply_routing_rules: {e}")
+
+ # ── Auto-save mittente nella rubrica (Feature 6) ──────────────────────────
+ # Per messaggi inbound di tipo posta_certificata, salva automaticamente
+ # il mittente nella rubrica pec_contacts del tenant (upsert idempotente).
+ if direction == "inbound" and pec_class.pec_type == "posta_certificata" and message.from_address:
+ try:
+ from sqlalchemy import text as _text
+ await db.execute(
+ _text("""
+ INSERT INTO pec_contacts (id, tenant_id, email, auto_saved, created_at, updated_at)
+ VALUES (gen_random_uuid(), :tenant_id, :email, true, now(), now())
+ ON CONFLICT (tenant_id, email) DO NOTHING
+ """),
+ {"tenant_id": str(mailbox.tenant_id), "email": message.from_address.lower().strip()},
+ )
+ await db.flush()
+ except Exception as e:
+ logger.debug(f"[{mailbox.email_address}] Auto-save contatto fallito (non critico): {e}")
+
return True
diff --git a/worker/app/jobs/apply_routing_rules.py b/worker/app/jobs/apply_routing_rules.py
new file mode 100644
index 0000000..d71273b
--- /dev/null
+++ b/worker/app/jobs/apply_routing_rules.py
@@ -0,0 +1,234 @@
+"""
+Job arq: apply_routing_rules – applica le regole di smistamento automatico.
+
+Viene accodato da sync.py dopo il salvataggio di ogni messaggio inbound.
+
+Logica:
+ 1. Carica le regole attive del tenant ordinate per priority
+ 2. Per ogni regola valuta le condizioni (AND)
+ 3. Se match: esegue le azioni (apply_label, mark_read, mark_starred, notify_webhook)
+ 4. Se stop_processing=True, interrompe la catena
+"""
+
+import logging
+import re
+import uuid as uuid_module
+from typing import Any
+
+from sqlalchemy import select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import AsyncSessionLocal
+from app.models import Message
+
+logger = logging.getLogger(__name__)
+
+
+# ─── Job principale ───────────────────────────────────────────────────────────
+
+async def apply_routing_rules(ctx: dict[str, Any], message_id: str) -> dict:
+ """
+ Valuta le regole di smistamento automatico per un messaggio.
+
+ Args:
+ ctx: contesto arq
+ message_id: UUID del messaggio da processare
+
+ Returns:
+ dict con: matched_rules, actions_applied
+ """
+ msg_uuid = uuid_module.UUID(message_id)
+
+ async with AsyncSessionLocal() as db:
+ # Carica il messaggio
+ msg = await db.get(Message, msg_uuid)
+ if not msg:
+ logger.warning(f"[routing_rules] Messaggio {message_id} non trovato")
+ return {"status": "skipped", "reason": "message_not_found"}
+
+ # Solo messaggi inbound di tipo posta_certificata
+ if msg.direction != "inbound" or msg.pec_type != "posta_certificata":
+ return {"status": "skipped", "reason": "not_inbound_pec"}
+
+ # Carica regole attive del tenant ordinate per priority ASC
+ rules_result = await db.execute(
+ text("""
+ SELECT r.id, r.name, r.priority, r.stop_processing,
+ COALESCE(
+ json_agg(
+ json_build_object('field', c.field, 'operator', c.operator, 'value', c.value)
+ ORDER BY c.id
+ ) FILTER (WHERE c.id IS NOT NULL),
+ '[]'::json
+ ) AS conditions,
+ COALESCE(
+ json_agg(
+ json_build_object('action_type', a.action_type, 'action_value', a.action_value)
+ ORDER BY a.id
+ ) FILTER (WHERE a.id IS NOT NULL),
+ '[]'::json
+ ) AS actions
+ FROM routing_rules r
+ LEFT JOIN routing_rule_conditions c ON c.rule_id = r.id
+ LEFT JOIN routing_rule_actions a ON a.rule_id = r.id
+ WHERE r.tenant_id = :tenant_id
+ AND r.is_active = true
+ GROUP BY r.id, r.name, r.priority, r.stop_processing
+ ORDER BY r.priority ASC
+ """),
+ {"tenant_id": str(msg.tenant_id)},
+ )
+ rules = rules_result.mappings().all()
+
+ matched_count = 0
+ actions_applied: list[str] = []
+
+ for rule in rules:
+ conditions = rule["conditions"]
+ if not conditions:
+ continue
+
+ # Valuta condizioni (AND)
+ if not _evaluate_conditions(msg, conditions):
+ continue
+
+ matched_count += 1
+ logger.info(
+ f"[routing_rules] Regola '{rule['name']}' (priority={rule['priority']}) "
+ f"match per messaggio {message_id}"
+ )
+
+ # Esegui azioni
+ for action in rule["actions"]:
+ applied = await _apply_action(db, msg, action["action_type"], action["action_value"])
+ if applied:
+ actions_applied.append(action["action_type"])
+
+ if rule["stop_processing"]:
+ break
+
+ if matched_count > 0:
+ await db.commit()
+
+ return {
+ "status": "ok",
+ "message_id": message_id,
+ "matched_rules": matched_count,
+ "actions_applied": actions_applied,
+ }
+
+
+# ─── Valutazione condizioni ────────────────────────────────────────────────────
+
+def _get_field_value(msg: Message, field: str) -> str:
+ if field == "from_address":
+ return (msg.from_address or "").lower()
+ elif field == "to_address":
+ return " ".join(msg.to_addresses or []).lower()
+ elif field == "subject":
+ return (msg.subject or "").lower()
+ elif field == "mailbox_id":
+ return str(msg.mailbox_id)
+ elif field == "pec_type":
+ return msg.pec_type or ""
+ return ""
+
+
+def _evaluate_condition(field_value: str, operator: str, value: str) -> bool:
+ v = value.lower()
+ fv = field_value.lower()
+ if operator == "contains":
+ return v in fv
+ elif operator == "not_contains":
+ return v not in fv
+ elif operator == "equals":
+ return fv == v
+ elif operator == "starts_with":
+ return fv.startswith(v)
+ elif operator == "ends_with":
+ return fv.endswith(v)
+ elif operator == "regex":
+ try:
+ return bool(re.search(value, field_value, re.IGNORECASE))
+ except re.error:
+ return False
+ return False
+
+
+def _evaluate_conditions(msg: Message, conditions: list[dict]) -> bool:
+ """Valuta AND tra tutte le condizioni."""
+ for cond in conditions:
+ field_val = _get_field_value(msg, cond["field"])
+ if not _evaluate_condition(field_val, cond["operator"], cond["value"]):
+ return False
+ return True
+
+
+# ─── Esecuzione azioni ─────────────────────────────────────────────────────────
+
+async def _apply_action(
+ db: AsyncSession,
+ msg: Message,
+ action_type: str,
+ action_value: str | None,
+) -> bool:
+ """Esegue una singola azione. Restituisce True se applicata."""
+ try:
+ if action_type == "apply_label" and action_value:
+ return await _action_apply_label(db, msg, uuid_module.UUID(action_value))
+ elif action_type == "mark_read":
+ if not msg.is_read:
+ msg.is_read = True
+ return True
+ elif action_type == "mark_starred":
+ if not msg.is_starred:
+ msg.is_starred = True
+ return True
+ elif action_type == "notify_webhook" and action_value:
+ await _action_notify_webhook(msg, action_value)
+ return True
+ except Exception as e:
+ logger.warning(f"[routing_rules] Errore azione {action_type}: {e}")
+ return False
+
+
+async def _action_apply_label(
+ db: AsyncSession, msg: Message, label_id: uuid_module.UUID
+) -> bool:
+ """Applica un'etichetta al messaggio se non gia' applicata."""
+ # Verifica che la label esista e appartenga al tenant
+ label_check = await db.execute(
+ text("SELECT id FROM labels WHERE id = :lid AND tenant_id = :tid"),
+ {"lid": str(label_id), "tid": str(msg.tenant_id)},
+ )
+ if not label_check.fetchone():
+ return False
+
+ # Inserisci con ON CONFLICT DO NOTHING per idempotenza
+ await db.execute(
+ text("""
+ INSERT INTO message_labels (message_id, label_id)
+ VALUES (:msg_id, :label_id)
+ ON CONFLICT DO NOTHING
+ """),
+ {"msg_id": str(msg.id), "label_id": str(label_id)},
+ )
+ logger.debug(f"[routing_rules] Etichetta {label_id} applicata a {msg.id}")
+ return True
+
+
+async def _action_notify_webhook(msg: Message, url: str) -> None:
+ """Invia notifica webhook per il messaggio."""
+ import aiohttp
+ payload = {
+ "event": "routing_rule_match",
+ "message_id": str(msg.id),
+ "subject": msg.subject,
+ "from_address": msg.from_address,
+ "pec_type": msg.pec_type,
+ }
+ try:
+ async with aiohttp.ClientSession() as session:
+ await session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=5))
+ except Exception as e:
+ logger.warning(f"[routing_rules] Webhook {url} fallito: {e}")
diff --git a/worker/app/main.py b/worker/app/main.py
index 1f8b248..4b8289f 100644
--- a/worker/app/main.py
+++ b/worker/app/main.py
@@ -24,6 +24,7 @@ from arq.connections import RedisSettings
from app.config import get_settings
from app.imap.pool import MailboxPool
+from app.jobs.apply_routing_rules import apply_routing_rules
from app.jobs.dispatch_notification import dispatch_notification
from app.jobs.send_pec import send_pec
from app.jobs.sync_mailbox import sync_mailbox
@@ -133,7 +134,7 @@ class WorkerSettings:
"""Configurazione del worker arq."""
# Funzioni/job registrati
- functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, health_check]
+ functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, apply_routing_rules, health_check]
# Callbacks lifecycle
on_startup = on_startup