From 46784aca4c0eee4b6569d367877b6b3627180e55 Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Fri, 27 Mar 2026 20:59:06 +0100 Subject: [PATCH] Implementazioni varie --- GapAnalysis.md | 12 +- .../alembic/versions/0010_add_templates.py | 55 +++ .../versions/0011_add_routing_rules.py | 110 +++++ backend/alembic/versions/0012_add_deadline.py | 35 ++ .../versions/0013_add_scheduled_send.py | 30 ++ .../alembic/versions/0014_add_pec_contacts.py | 60 +++ backend/app/api/v1/contacts.py | 132 ++++++ backend/app/api/v1/deadlines.py | 152 +++++++ backend/app/api/v1/messages.py | 281 +++++++++++++ backend/app/api/v1/routing_rules.py | 106 +++++ backend/app/api/v1/templates.py | 83 ++++ backend/app/main.py | 6 +- backend/app/models/__init__.py | 3 + backend/app/models/message.py | 6 + backend/app/models/pec_contact.py | 49 +++ backend/app/models/routing_rule.py | 114 +++++ backend/app/models/template.py | 45 ++ backend/app/schemas/pec_contact.py | 56 +++ backend/app/schemas/routing_rule.py | 113 +++++ backend/app/schemas/send.py | 4 + backend/app/schemas/template.py | 51 +++ backend/app/services/pec_contact_service.py | 240 +++++++++++ backend/app/services/routing_rule_service.py | 296 +++++++++++++ backend/app/services/send_service.py | 17 +- backend/app/services/template_service.py | 116 ++++++ frontend/src/App.tsx | 10 + frontend/src/api/contacts.api.ts | 65 +++ frontend/src/api/deadlines.api.ts | 31 ++ frontend/src/api/messages.api.ts | 23 + frontend/src/api/routing_rules.api.ts | 65 +++ frontend/src/api/templates.api.ts | 49 +++ frontend/src/components/Layout/Sidebar.tsx | 40 +- frontend/src/pages/Contacts/ContactsPage.tsx | 282 +++++++++++++ .../src/pages/Deadlines/DeadlinesPage.tsx | 190 +++++++++ .../pages/MessageDetail/MessageDetailPage.tsx | 312 +++++++++++++- .../pages/RoutingRules/RoutingRulesPage.tsx | 394 ++++++++++++++++++ .../src/pages/Templates/TemplatesPage.tsx | 228 ++++++++++ worker/app/imap/sync.py | 26 ++ worker/app/jobs/apply_routing_rules.py | 234 +++++++++++ worker/app/main.py | 3 +- 40 files changed, 4090 insertions(+), 34 deletions(-) create mode 100644 backend/alembic/versions/0010_add_templates.py create mode 100644 backend/alembic/versions/0011_add_routing_rules.py create mode 100644 backend/alembic/versions/0012_add_deadline.py create mode 100644 backend/alembic/versions/0013_add_scheduled_send.py create mode 100644 backend/alembic/versions/0014_add_pec_contacts.py create mode 100644 backend/app/api/v1/contacts.py create mode 100644 backend/app/api/v1/deadlines.py create mode 100644 backend/app/api/v1/routing_rules.py create mode 100644 backend/app/api/v1/templates.py create mode 100644 backend/app/models/pec_contact.py create mode 100644 backend/app/models/routing_rule.py create mode 100644 backend/app/models/template.py create mode 100644 backend/app/schemas/pec_contact.py create mode 100644 backend/app/schemas/routing_rule.py create mode 100644 backend/app/schemas/template.py create mode 100644 backend/app/services/pec_contact_service.py create mode 100644 backend/app/services/routing_rule_service.py create mode 100644 backend/app/services/template_service.py create mode 100644 frontend/src/api/contacts.api.ts create mode 100644 frontend/src/api/deadlines.api.ts create mode 100644 frontend/src/api/routing_rules.api.ts create mode 100644 frontend/src/api/templates.api.ts create mode 100644 frontend/src/pages/Contacts/ContactsPage.tsx create mode 100644 frontend/src/pages/Deadlines/DeadlinesPage.tsx create mode 100644 frontend/src/pages/RoutingRules/RoutingRulesPage.tsx create mode 100644 frontend/src/pages/Templates/TemplatesPage.tsx create mode 100644 worker/app/jobs/apply_routing_rules.py 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

+ + + {receipt_rows} +
Tipo ricevutaData
+
""" + + 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)'}

    +
    +

    {from_label}: {from_val}

    + {'

    Da: ' + message.from_address + '

    ' if message.direction == "outbound" and message.from_address else ''} + {'

    A: ' + ', '.join(message.to_addresses or []) + '

    ' if message.direction == "inbound" and message.to_addresses else ''} + {'

    Cc: ' + ', '.join(message.cc_addresses or []) + '

    ' if message.cc_addresses else ''} +

    Data: {date_val}

    +

    Stato: {message.state}

    +

    Tipo: {message.pec_type}

    +
    +{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 +

    +
    + ) : ( +
    + + + + + + + + + + + + {items.map((c) => ( + + + + + + + + + ))} + +
    Email PECNomeOrganizzazioneTipoAggiornato +
    {c.email}{c.name ?? '-'} + {c.organization ? ( + + + {c.organization} + + ) : '-'} + + + {c.auto_saved ? 'Automatico' : 'Manuale'} + + {formatDate(c.updated_at)} +
    + + + +
    +
    +
    + )} +
    + + !o && closeForm()}> + + + {editing ? 'Modifica contatto' : 'Nuovo contatto'} + + +
    +
    + + setFormEmail(e.target.value)} placeholder="indirizzo@pec.it" disabled={!!editing} type="email" /> +
    +
    + + setFormName(e.target.value)} placeholder="Mario Rossi" /> +
    +
    + + setFormOrg(e.target.value)} placeholder="Comune di Roma" /> +
    +
    + + setFormNotes(e.target.value)} placeholder="Note aggiuntive..." /> +
    + +
    + + + + + +
    +
    +
    + ) +} 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/') ? ( + {filename} + ) : contentType === 'application/pdf' ? ( +