diff --git a/.gitignore b/.gitignore
index 6c4d8e0..4d26537 100644
--- a/.gitignore
+++ b/.gitignore
@@ -161,3 +161,4 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
+KnowledgeBaseCline.md
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index e288c78..0941f76 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -888,44 +888,6 @@ END $$;
## 4. Decisioni Architetturali
----
-
-### ADR-001 – Multi-tenancy: Schema-per-tenant vs Row-level con tenant_id
-
-**Opzione A – Schema-per-tenant (un PostgreSQL schema per ogni organizzazione)**
-
-*Pro:*
-- Isolamento totale dei dati: impossibile data leak cross-tenant per bug SQL
-- Backup e restore per singolo tenant molto semplici
-- Possibilità di migrare un tenant su un DB separato senza refactoring
-
-*Contro:*
-- Alembic migrations vanno applicate a tutti gli schema (N schema × migrazione)
-- Connection pooling complesso: PgBouncer deve gestire schema switching
-- Difficile fare query aggregate cross-tenant (es. monitoraggio globale SaaS)
-- Overhead operativo significativo oltre i 100 tenant
-
-**Opzione B – Row-level con `tenant_id` + PostgreSQL RLS**
-
-*Pro:*
-- Un solo schema, migrations applicate una volta
-- Query cross-tenant per operazioni di sistema (monitoring, billing)
-- Molto più semplice da gestire con Alembic e ORM
-- Scala bene fino a decine di migliaia di tenant
-
-*Contro:*
-- Bug nell'impostazione `current_tenant_id` può causare data leak → mitigato con RLS come secondo livello
-- Restore di singolo tenant richiede WHERE clause su dump
-
-**Raccomandazione: Opzione B (row-level + RLS)**
-
-Per un SaaS B2B con caselle PEC, il numero di tenant nel medio termine è nell'ordine delle centinaia, non decine di migliaia. Il rischio principale (data leak cross-tenant) è mitigato da due strati indipendenti:
-1. Applicativo: ogni query include `WHERE tenant_id = :current_tenant_id`
-2. DB: PostgreSQL RLS come safety net, impostata tramite `SET LOCAL app.current_tenant_id = '...'` in ogni transazione
-
-Il vantaggio operativo (migrazioni semplici, query di monitoring, pool unico) supera i rischi, a condizione di avere test di integrazione che verificano il corretto isolamento.
-
----
### ADR-002 – Cifratura credenziali IMAP/SMTP a riposo
diff --git a/GapAnalysis.md b/GapAnalysis.md
new file mode 100644
index 0000000..a4bc308
--- /dev/null
+++ b/GapAnalysis.md
@@ -0,0 +1,113 @@
+Report Gap Analysis – PEChub PEC Manager SaaS
+Analisi condotta il 25/03/2026 sul codice sorgente (backend, worker, frontend).
+
+COSA E' IMPLEMENTATO E FUNZIONANTE
+Infrastruttura e autenticazione
+
+Stack Docker completo (PostgreSQL, Redis, MinIO, Nginx, backend, worker, frontend)
+Autenticazione JWT con refresh token silenzioso
+2FA TOTP (setup + verifica)
+Multi-tenancy row-level con RLS PostgreSQL
+Cifratura credenziali IMAP/SMTP (AES-256-GCM in security.py)
+Rate limiting su endpoint auth
+WebSocket real-time per nuovi messaggi
+CRUD completo: caselle, utenti, permessi, Virtual Box, notifiche, etichette
+IMAP Sync Engine
+
+Pool asincrono con N coroutine IMAP, IDLE + polling fallback
+Backoff esponenziale su disconnessione
+Download EML grezzo su MinIO
+Aggiornamento stato casella (error, sync_error_count)
+Parser PEC
+
+Classificazione tipo messaggio da header X-Ricevuta/X-TipoRicevuta
+Parsing MIME completo, estrazione allegati
+EML-in-EML (ricevute annidate)
+State machine outbound: sent → accepted → delivered / anomaly
+Invio SMTP
+
+API POST /send con validazione e creazione send_job
+Job send_pec con retry esponenziale (5 tentativi)
+receipt_watcher: attesa ricevuta accettazione con alert anomalia a 24h
+Upload raw EML inviato su MinIO
+Frontend
+
+Inbox multi-casella con filtri, selezione multipla, azioni bulk
+Posta inviata, Preferiti, Archiviati, Cestino
+Dettaglio messaggio: corpo HTML/testo, allegati, ReceiptTree, download ZIP
+Composizione PEC con RichTextEditor, To/Cc multipli, allegati
+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
+
+3. Archiviazione Sostitutiva (Fase 6 – ~15% implementata)
+
+worker/app/archival/conservatore_client.py esiste (mock + produzione) ma non e' mai chiamato da nessun job reale
+Mancano completamente:
+worker/app/archival/sip_builder.py (generazione pacchetto SIP UNI SInCRO)
+worker/app/archival/rdv_processor.py (parsing RdV XML)
+worker/app/jobs/archive_batch.py (job selezione messaggi + upload SIP)
+backend/app/api/v1/archival.py (endpoint GET /archival/batches, POST /archival/dip)
+frontend/src/pages/Archival/ (pagina log versamenti, download RdV, richiesta DIP)
+Il modello archival.py esiste ma la tabella archival_batches non e' nella migrazione corrente
+La configurazione conservatore nelle impostazioni tenant e' pronta, ma il "pulsante" che avvia il versamento non esiste
+
+
+6. Worker – job mancanti
+
+dispatch_notification.py – notifiche automatiche
+archive_batch.py – versamenti verso conservatore
+generate_report.py – export PDF/CSV
+index_message.py – indicizzazione FTS allegati via Tika
+7. Sicurezza – punti critici
+
+La cifratura dei segreti notifiche usa base64.b64encode() senza encryption reale: chiunque abbia accesso al DB puo' leggere bot_token Telegram, webhook secret, SMTP password in chiaro
+Il CI/CD GitHub Actions e' disabilitato (ci.yml.bak): non c'e' lint automatico, test o build su PR
+Non c'e' docker-compose.prod.yml (override produzione con configurazioni rafforzate)
+Docs /docs, /redoc sono disabilitate in produzione ma non c'e' un meccanismo di secret scan
+
+
+10. Gestione quote casella
+
+L'evento mailbox.quota_warning e' definito negli enum delle notifiche ma non e' mai generato dal worker (nessuna stima della quota IMAP)
+COSA MANCA – PRIORITA' BASSA (Hardening / Go-Live)
+11. Monitoring e osservabilita'
+
+Non c'e' infra/prometheus/ ne' infra/grafana/ (previsti in ARCHITECTURE.md ma non creati)
+Non c'e' log aggregation (Loki/ELK)
+Non ci sono metriche esposte dal backend (es. /metrics endpoint Prometheus)
+12. Backup automatico
+
+Non c'e' script o cronjob per pg_dump automatico verso MinIO
+13. Test coverage
+
+I test di integrazione esistenti coprono auth, users, send API
+Non ci sono test per: messages API, permissions API, virtual_boxes, notifications, archival
+Non c'e' copertura frontend (nessun test Vitest/Playwright presente)
+14. GDPR
+
+Non c'e' endpoint DELETE /tenants/{id} per cancellazione completa dati tenant con audit trail
+RIEPILOGO STATO PER FASE
+Fase Descrizione Stato
+1 Fondamenta + Auth + Multi-tenancy Completa
+1-A Permessi granulari per casella Completa
+2 IMAP Sync Engine Completa
+3 Parser PEC e Tracking Ricevute Completa
+4 Invio SMTP con retry Completa
+5 Frontend base (inbox, compose, admin) Completa
+5-A Virtual Box Completa
+5-B Ricerca avanzata full-text Non iniziata
+5-C Notifiche multi-canale Struttura pronta, dispatch mancante
+6 Archiviazione sostitutiva ~15% (client mock presente, tutto il resto mancante)
+7 Dashboard e Reportistica Non iniziata
+8 Hardening, test, go-live Parziale (sicurezza base presente, monitoring/backup/CI mancanti)
+PRIORITA' DI INTERVENTO CONSIGLIATA
+Notifiche dispatch – e' la funzionalita' piu' vicina al completamento: la struttura dati e' pronta, manca solo il wiring tra IMAP sync, il servizio di valutazione regole e il job worker. Ha alto impatto operativo.
+Ricerca avanzata – blocca l'usabilita' su volumi di posta significativi. La ricerca ILIKE attuale scala male.
+Archiviazione sostitutiva – obbligatoria per la compliance normativa dei clienti PA/professionisti.
+Fix sicurezza cifratura notifiche – critico: i segreti (bot token, webhook secret) sono attualmente non cifrati nel DB.
+Audit log – necessario per compliance e per l'utilizzo enterprise.
+Dashboard/Report – utile commercialmente ma non bloccante per l'operativita'.
\ No newline at end of file
diff --git a/KnowledgeBaseCline.md b/KnowledgeBaseCline.md
index 27e3624..d2662f1 100644
--- a/KnowledgeBaseCline.md
+++ b/KnowledgeBaseCline.md
@@ -30,7 +30,7 @@ Porta: 465
SSL: Sì
-Se devi, effettua i test di invio solo al destinatario matteo1801@spidmail.it
+Quando necessario, effettua i test di invio solo al destinatario matteo1801@spidmail.it
Tutto il frontend deve essere in italiano
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 036dd8c..4fca4c8 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -5,6 +5,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
gcc \
libpq-dev \
+ tesseract-ocr \
+ tesseract-ocr-ita \
+ tesseract-ocr-eng \
+ poppler-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
diff --git a/backend/alembic/versions/0007_add_trash.py b/backend/alembic/versions/0007_add_trash.py
new file mode 100644
index 0000000..d784a44
--- /dev/null
+++ b/backend/alembic/versions/0007_add_trash.py
@@ -0,0 +1,31 @@
+"""add is_trashed and trashed_at to messages
+
+Revision ID: 0007
+Revises: 0006
+Create Date: 2026-03-25
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '0007'
+down_revision = '0006'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ 'messages',
+ sa.Column('is_trashed', sa.Boolean(), nullable=False, server_default=sa.text('false')),
+ )
+ op.add_column(
+ 'messages',
+ sa.Column('trashed_at', sa.DateTime(timezone=True), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column('messages', 'trashed_at')
+ op.drop_column('messages', 'is_trashed')
diff --git a/backend/alembic/versions/0008_full_text_search.py b/backend/alembic/versions/0008_full_text_search.py
new file mode 100644
index 0000000..a173f77
--- /dev/null
+++ b/backend/alembic/versions/0008_full_text_search.py
@@ -0,0 +1,76 @@
+"""add full text search vector to messages and extracted_text to attachments
+
+Revision ID: 0008
+Revises: 0007
+Create Date: 2026-03-25
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision = '0008'
+down_revision = '0007'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # 1. Aggiunge colonna search_vector a messages
+ op.add_column(
+ 'messages',
+ sa.Column('search_vector', postgresql.TSVECTOR(), nullable=True),
+ )
+
+ # 2. Aggiunge colonna extracted_text ad attachments (testo estratto da PDF/DOCX)
+ op.add_column(
+ 'attachments',
+ sa.Column('extracted_text', sa.Text(), nullable=True),
+ )
+
+ # 3. Indice GIN per ricerca full-text veloce
+ op.execute(
+ "CREATE INDEX idx_messages_fts ON messages USING gin(search_vector) "
+ "WHERE search_vector IS NOT NULL"
+ )
+
+ # 4. Funzione trigger che aggiorna search_vector quando cambiano i campi testuali
+ op.execute("""
+ CREATE OR REPLACE FUNCTION messages_search_vector_update() RETURNS trigger AS $$
+ BEGIN
+ NEW.search_vector :=
+ setweight(to_tsvector('italian', coalesce(NEW.subject, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(NEW.from_address, '')), 'B') ||
+ setweight(to_tsvector('simple',
+ coalesce(array_to_string(NEW.to_addresses, ' '), '')), 'B') ||
+ setweight(to_tsvector('italian', coalesce(NEW.body_text, '')), 'C');
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql;
+ """)
+
+ # 5. Crea trigger (si attiva su INSERT e UPDATE dei campi rilevanti)
+ op.execute("""
+ CREATE TRIGGER trg_messages_search_vector
+ BEFORE INSERT OR UPDATE OF subject, from_address, to_addresses, body_text
+ ON messages
+ FOR EACH ROW EXECUTE FUNCTION messages_search_vector_update();
+ """)
+
+ # 6. Backfill: popola search_vector per i messaggi esistenti
+ op.execute("""
+ UPDATE messages SET search_vector =
+ setweight(to_tsvector('italian', coalesce(subject, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') ||
+ setweight(to_tsvector('simple',
+ coalesce(array_to_string(to_addresses, ' '), '')), 'B') ||
+ setweight(to_tsvector('italian', coalesce(body_text, '')), 'C')
+ WHERE search_vector IS NULL
+ """)
+
+
+def downgrade() -> None:
+ op.execute("DROP TRIGGER IF EXISTS trg_messages_search_vector ON messages")
+ op.execute("DROP FUNCTION IF EXISTS messages_search_vector_update()")
+ op.execute("DROP INDEX IF EXISTS idx_messages_fts")
+ op.drop_column('attachments', 'extracted_text')
+ op.drop_column('messages', 'search_vector')
diff --git a/backend/alembic/versions/0009_add_conservation.py b/backend/alembic/versions/0009_add_conservation.py
new file mode 100644
index 0000000..484b7c7
--- /dev/null
+++ b/backend/alembic/versions/0009_add_conservation.py
@@ -0,0 +1,95 @@
+"""add conservation fields to messages and mailbox_permissions
+
+Revision ID: 0009
+Revises: 0008
+Create Date: 2026-03-27
+
+Aggiunge:
+ - messages.is_pending_conservation BOOLEAN NOT NULL DEFAULT FALSE
+ - messages.pending_conservation_at TIMESTAMP WITH TIME ZONE
+ - messages.is_conserved BOOLEAN NOT NULL DEFAULT FALSE
+ - messages.conserved_at TIMESTAMP WITH TIME ZONE
+ - mailbox_permissions.can_conserve BOOLEAN NOT NULL DEFAULT FALSE
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers
+revision = "0009"
+down_revision = "0008"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Nuovi campi sulla tabella messages
+ op.add_column(
+ "messages",
+ sa.Column(
+ "is_pending_conservation",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ )
+ op.add_column(
+ "messages",
+ sa.Column(
+ "pending_conservation_at",
+ sa.DateTime(timezone=True),
+ nullable=True,
+ ),
+ )
+ op.add_column(
+ "messages",
+ sa.Column(
+ "is_conserved",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ )
+ op.add_column(
+ "messages",
+ sa.Column(
+ "conserved_at",
+ sa.DateTime(timezone=True),
+ nullable=True,
+ ),
+ )
+
+ # Indici parziali per query sulle cartelle Conservazione
+ op.create_index(
+ "idx_messages_pending_conservation",
+ "messages",
+ ["tenant_id"],
+ postgresql_where=sa.text("is_pending_conservation = true"),
+ )
+ op.create_index(
+ "idx_messages_conserved",
+ "messages",
+ ["tenant_id"],
+ postgresql_where=sa.text("is_conserved = true"),
+ )
+
+ # Permesso can_conserve sulla tabella mailbox_permissions
+ op.add_column(
+ "mailbox_permissions",
+ sa.Column(
+ "can_conserve",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("mailbox_permissions", "can_conserve")
+ op.drop_index("idx_messages_conserved", table_name="messages")
+ op.drop_index("idx_messages_pending_conservation", table_name="messages")
+ op.drop_column("messages", "conserved_at")
+ op.drop_column("messages", "is_conserved")
+ op.drop_column("messages", "pending_conservation_at")
+ op.drop_column("messages", "is_pending_conservation")
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/audit_log.py b/backend/app/api/v1/audit_log.py
new file mode 100644
index 0000000..c3d9244
--- /dev/null
+++ b/backend/app/api/v1/audit_log.py
@@ -0,0 +1,65 @@
+"""
+Router Audit Log – consultazione degli eventi di sistema.
+
+Endpoint:
+ GET /api/v1/audit-log – lista paginata con filtri (solo admin/super_admin)
+
+Permessi:
+ - admin: vede solo gli eventi del proprio tenant
+ - super_admin: vede tutti i tenant (filtrabile per tenant_id)
+"""
+
+import uuid
+from datetime import datetime
+from typing import Optional
+
+from fastapi import APIRouter, Query
+
+from app.dependencies import AdminUser, DB
+from app.schemas.audit_log import AuditLogListResponse
+from app.services.audit_service import AuditService
+
+router = APIRouter(prefix="/audit-log", tags=["Audit Log"])
+
+
+@router.get("", response_model=AuditLogListResponse)
+async def list_audit_log(
+ current_user: AdminUser,
+ db: DB,
+ page: int = Query(1, ge=1, description="Numero di pagina"),
+ page_size: int = Query(25, ge=1, le=100, description="Elementi per pagina"),
+ action: Optional[str] = Query(None, description="Filtra per azione (es. auth.login, user.*)"),
+ user_id: Optional[uuid.UUID] = Query(None, description="Filtra per utente"),
+ outcome: Optional[str] = Query(None, pattern="^(success|failure)$", description="Esito: success o failure"),
+ date_from: Optional[datetime] = Query(None, description="Data inizio (ISO 8601)"),
+ date_to: Optional[datetime] = Query(None, description="Data fine (ISO 8601)"),
+ resource_type: Optional[str] = Query(None, description="Tipo risorsa (user, mailbox, message, ...)"),
+ tenant_id: Optional[uuid.UUID] = Query(None, description="Filtra per tenant (solo super_admin)"),
+) -> AuditLogListResponse:
+ """
+ Restituisce la lista paginata degli eventi di audit.
+
+ - Admin: vede solo gli eventi del proprio tenant (tenant_id ignorato).
+ - Super Admin: vede tutti i tenant, filtrabile per tenant_id.
+ """
+ svc = AuditService(db)
+
+ # Determina il tenant_id effettivo da applicare al filtro
+ if current_user.is_super_admin:
+ # Super admin: usa il tenant_id passato come filtro (None = tutti)
+ effective_tenant_id = tenant_id
+ else:
+ # Admin normale: sempre vincolato al proprio tenant
+ effective_tenant_id = current_user.tenant_id
+
+ return await svc.list(
+ tenant_id=effective_tenant_id,
+ page=page,
+ page_size=page_size,
+ action=action,
+ user_id=user_id,
+ outcome=outcome,
+ date_from=date_from,
+ date_to=date_to,
+ resource_type=resource_type,
+ )
diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
index 89815c1..1ac2146 100644
--- a/backend/app/api/v1/auth.py
+++ b/backend/app/api/v1/auth.py
@@ -161,13 +161,34 @@ async def totp_disable(
summary="Cambio password utente corrente",
)
async def change_password(
+ request: Request,
body: PasswordChangeRequest,
current_user: CurrentUser,
db: DB,
) -> None:
from app.core.security import verify_password, hash_password
+ from app.services.audit_service import log_audit
if not verify_password(body.current_password, current_user.password_hash):
+ from app.services.audit_service import log_audit as _la
+ await _la(
+ db,
+ "auth.password_changed",
+ tenant_id=current_user.tenant_id,
+ user_id=current_user.id,
+ outcome="failure",
+ ip_address=request.client.host if request.client else None,
+ user_agent=request.headers.get("user-agent"),
+ payload={"reason": "wrong_current_password"},
+ )
raise InvalidCredentialsError()
current_user.password_hash = hash_password(body.new_password)
+ await log_audit(
+ db,
+ "auth.password_changed",
+ tenant_id=current_user.tenant_id,
+ user_id=current_user.id,
+ ip_address=request.client.host if request.client else None,
+ user_agent=request.headers.get("user-agent"),
+ )
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/mailboxes.py b/backend/app/api/v1/mailboxes.py
index a0d9c7a..a219b45 100644
--- a/backend/app/api/v1/mailboxes.py
+++ b/backend/app/api/v1/mailboxes.py
@@ -21,6 +21,8 @@ from app.schemas.mailbox import (
MailboxCreateRequest,
MailboxListResponse,
MailboxResponse,
+ MailboxSyncResponse,
+ MailboxUnreadCountsResponse,
MailboxUpdateRequest,
)
from app.services.mailbox_service import MailboxService
@@ -75,15 +77,15 @@ async def list_mailboxes(
"""
svc = _svc(db)
- if current_user.is_admin:
- # Admin: tutte le caselle del tenant
+ if current_user.is_supervisor_or_admin:
+ # Admin e supervisor: tutte le caselle del tenant
items, total = await svc.list_mailboxes(
tenant_id=current_user.tenant_id,
page=page,
page_size=page_size,
)
else:
- # Operatori: caselle con permesso
+ # Operator e readonly: caselle con permesso esplicito
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
visible_ids = await perm_svc.get_visible_mailboxes(current_user)
@@ -123,6 +125,54 @@ async def list_mailboxes(
)
+@router.get("/unread-counts", response_model=MailboxUnreadCountsResponse)
+async def get_unread_counts(
+ current_user: CurrentUser,
+ db: DB,
+) -> MailboxUnreadCountsResponse:
+ """
+ Restituisce il numero di messaggi non letti per ciascuna casella accessibile.
+ Usato dalla sidebar per mostrare i badge per casella.
+ - Admin: conta su tutte le caselle del tenant.
+ - Operatori: solo le caselle con permesso can_read.
+ """
+ from sqlalchemy import func, select
+ from app.models.message import Message
+
+ # Determina le caselle visibili
+ # Admin e supervisor: nessun filtro (accesso a tutto il tenant)
+ # Operator e readonly: solo caselle con permesso esplicito can_read
+ if current_user.is_supervisor_or_admin:
+ visible_ids = None # nessun filtro
+ else:
+ from app.services.permission_service import PermissionService
+ perm_svc = PermissionService(db)
+ visible_ids = await perm_svc.get_visible_mailboxes(current_user)
+ if not visible_ids:
+ return MailboxUnreadCountsResponse(counts={})
+
+ q = (
+ select(Message.mailbox_id, func.count().label("cnt"))
+ .where(
+ Message.tenant_id == current_user.tenant_id,
+ Message.is_read == False, # noqa: E712
+ Message.direction == "inbound",
+ Message.is_trashed == False, # noqa: E712
+ Message.is_archived == False, # noqa: E712
+ Message.parent_message_id.is_(None),
+ )
+ .group_by(Message.mailbox_id)
+ )
+
+ if visible_ids is not None:
+ from app.models.mailbox import Mailbox
+ q = q.where(Message.mailbox_id.in_(visible_ids))
+
+ rows = (await db.execute(q)).all()
+ counts = {str(row.mailbox_id): row.cnt for row in rows}
+ return MailboxUnreadCountsResponse(counts=counts)
+
+
@router.get("/{mailbox_id}", response_model=MailboxResponse)
async def get_mailbox(
mailbox_id: uuid.UUID,
@@ -200,3 +250,52 @@ async def test_mailbox_connection(
tenant_id=current_user.tenant_id,
data=data,
)
+
+
+@router.post(
+ "/{mailbox_id}/sync",
+ response_model=MailboxSyncResponse,
+)
+async def force_sync_mailbox(
+ mailbox_id: uuid.UUID,
+ current_user: AdminUser,
+ db: DB,
+) -> MailboxSyncResponse:
+ """
+ Forza una sincronizzazione IMAP immediata della casella.
+ Accoda il job sync_mailbox nel worker tramite arq/Redis.
+ Utile dopo un errore di connessione o per forzare un aggiornamento.
+ Richiede ruolo **admin**.
+ """
+ from app.core.exceptions import NotFoundError
+
+ svc = _svc(db)
+ mailbox = await svc.get_mailbox(mailbox_id, current_user.tenant_id)
+
+ if mailbox.status == "deleted":
+ raise NotFoundError("Casella non trovata o eliminata")
+
+ try:
+ from arq.connections import RedisSettings, create_pool as arq_create_pool
+ from app.config import get_settings
+
+ cfg = get_settings()
+ arq_settings = RedisSettings.from_dsn(cfg.redis_url)
+ arq_redis = await arq_create_pool(arq_settings)
+ await arq_redis.enqueue_job("sync_mailbox", str(mailbox_id))
+ await arq_redis.aclose()
+ except Exception as exc:
+ from app.core.logging import get_logger
+ logger = get_logger(__name__)
+ logger.error(f"[force_sync] Impossibile accodare job per {mailbox_id}: {exc}")
+ return MailboxSyncResponse(
+ status="error",
+ mailbox_id=mailbox_id,
+ message=f"Impossibile accodare il job: {exc}",
+ )
+
+ return MailboxSyncResponse(
+ status="enqueued",
+ mailbox_id=mailbox_id,
+ message=f"Sincronizzazione avviata per {mailbox.email_address}",
+ )
diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py
index 1661341..407c2e3 100644
--- a/backend/app/api/v1/messages.py
+++ b/backend/app/api/v1/messages.py
@@ -2,17 +2,21 @@
Router messaggi PEC.
Fornisce:
- - GET /messages – lista messaggi con filtri (inbox/sent/search/...)
- - GET /messages/{id} – singolo messaggio
- - PATCH /messages/{id} – aggiorna flags (is_read, is_starred, is_archived)
- - GET /messages/{id}/attachments – lista allegati
- - GET /messages/{id}/attachments/{att_id}/download – scarica allegato da MinIO
- - GET /messages/{id}/receipts – ricevute (messaggi figlio)
+ - GET /messages - lista messaggi con filtri (inbox/sent/search/...)
+ - GET /messages/{id} - singolo messaggio
+ - PATCH /messages/{id} - aggiorna flags (is_read, is_starred, is_archived, is_trashed,
+ is_pending_conservation, is_conserved)
+ - PATCH /messages/bulk - aggiorna in blocco piu messaggi
+ - GET /messages/{id}/attachments - lista allegati
+ - GET /messages/{id}/attachments/{att_id}/download - scarica allegato da MinIO
+ - GET /messages/{id}/receipts - ricevute (messaggi figlio)
Permessi:
- Admin: accede a tutti i messaggi del proprio tenant.
- Operator/Supervisor/Readonly: solo i messaggi delle caselle su cui
hanno almeno il permesso can_read.
+ - is_pending_conservation / is_conserved: richiedono can_conserve
+ (implicito per admin, esplicito per supervisor/operator).
"""
import uuid
@@ -25,6 +29,8 @@ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
+from app.services.search_service import SearchService
+
from app.config import get_settings
from app.core.exceptions import ForbiddenError, NotFoundError
from app.database import get_db
@@ -58,7 +64,6 @@ def _apply_vbox_rule(q, field: str, operator: str, value: str):
elif field == "from_address":
col = Message.from_address
elif field == "to_address":
- # to_addresses è ARRAY(Text) – converte in stringa per il confronto
arr_text = func.array_to_string(Message.to_addresses, ",")
if operator == "contains":
return q.where(arr_text.ilike(f"%{value}%"))
@@ -74,7 +79,7 @@ def _apply_vbox_rule(q, field: str, operator: str, value: str):
elif field == "imap_folder":
col = Message.imap_folder
else:
- return q # campo non supportato – ignorato
+ return q
if operator == "contains":
return q.where(col.ilike(f"%{value}%"))
@@ -93,11 +98,11 @@ async def _get_visible_mailbox_ids(
user, db: AsyncSession
) -> Optional[list[uuid.UUID]]:
"""
- Per utenti non-admin restituisce la lista di mailbox_id accessibili.
- Restituisce None se l'utente è admin (accesso illimitato al tenant).
+ Per utenti non-admin/supervisor restituisce la lista di mailbox_id accessibili.
+ Restituisce None se l'utente e' admin o supervisor (accesso illimitato al tenant).
"""
- if user.is_admin:
- return None # nessun filtro per admin
+ if user.is_supervisor_or_admin:
+ return None
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
@@ -109,13 +114,7 @@ async def _resolve_message(
current_user,
db: AsyncSession,
) -> Message:
- """Carica il messaggio e verifica i permessi di accesso.
-
- L'accesso è consentito se:
- 1. L'utente è admin del tenant, oppure
- 2. L'utente ha un permesso diretto can_read sulla casella, oppure
- 3. L'utente è assegnato a una Virtual Box attiva che include la casella.
- """
+ """Carica il messaggio e verifica i permessi di accesso."""
result = await db.execute(
select(Message)
.where(
@@ -134,9 +133,6 @@ async def _resolve_message(
has_direct_access = await perm_svc.check_can_read(current_user, message.mailbox_id)
if not has_direct_access:
- # Verifica accesso tramite Virtual Box:
- # l'utente deve essere assegnato a una VBox attiva
- # che include la casella del messaggio.
from app.models.virtual_box import (
VirtualBox,
VirtualBoxAssignment,
@@ -182,9 +178,13 @@ async def list_messages(
is_read: Optional[bool] = Query(None),
is_starred: Optional[bool] = Query(None),
is_archived: Optional[bool] = Query(False),
- search: Optional[str] = Query(None, max_length=200),
+ is_trashed: Optional[bool] = Query(False),
+ is_pending_conservation: Optional[bool] = Query(None, description="Filtra per messaggi in attesa di conservazione"),
+ is_conserved: Optional[bool] = Query(None, description="Filtra per messaggi gia' conservati"),
+ search: Optional[str] = Query(None, max_length=500),
pec_type: Optional[str] = Query(None),
- # Paginazione
+ date_from: Optional[datetime] = Query(None, description="Data minima (received_at o sent_at)"),
+ date_to: Optional[datetime] = Query(None, description="Data massima (received_at o sent_at)"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> MessageListResponse:
@@ -192,10 +192,11 @@ async def list_messages(
Elenca i messaggi PEC con filtri opzionali.
- `is_archived=False` (default) esclude i messaggi archiviati.
- - `search` cerca su subject, from_address, to_addresses.
- - `vbox_id` filtra per Virtual Box assegnata all'utente corrente.
+ - `is_trashed=False` (default) esclude i messaggi nel cestino.
+ - `is_pending_conservation` filtra messaggi da conservare (cartella Da Conservare).
+ - `is_conserved` filtra messaggi gia' conservati (cartella Storico).
+ - `search` usa ricerca full-text (tsvector) con fallback ILIKE.
"""
- # Determinare le caselle visibili (normale check permessi)
visible_mailbox_ids = await _get_visible_mailbox_ids(current_user, db)
# ── Filtro Virtual Box ────────────────────────────────────────────────────
@@ -219,7 +220,6 @@ async def list_messages(
if not vbox:
raise NotFoundError("Virtual Box")
- # Non-admin: verifica che l'utente sia assegnato alla VBox
if not current_user.is_admin:
assign_result = await db.execute(
select(VirtualBoxAssignment).where(
@@ -230,32 +230,23 @@ async def list_messages(
if not assign_result.scalar_one_or_none():
raise ForbiddenError("Virtual Box non accessibile")
- # L'assegnazione alla VBox garantisce accesso alle sue caselle:
- # sovrascrive il filtro permessi normali per questa query.
if vbox.mailboxes:
visible_mailbox_ids = [m.id for m in vbox.mailboxes]
- # Se la VBox non ha caselle esplicitamente associate,
- # si mantiene il filtro permessi normale (visible_mailbox_ids invariato).
vbox_rules = vbox.rules or []
- # ─────────────────────────────────────────────────────────────────────────
# Query base
q = select(Message).where(
Message.tenant_id == current_user.tenant_id,
- Message.parent_message_id.is_(None), # escludi ricevute (messaggi figlio)
+ Message.parent_message_id.is_(None),
)
- # Filtro caselle visibili per non-admin (o dopo override VBox)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
- # Nessuna casella accessibile → lista vuota
return MessageListResponse(items=[], total=0, page=page, page_size=page_size)
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
- # Filtri opzionali
if mailbox_id is not None:
- # Verifica che l'utente abbia accesso a questa casella specifica
if visible_mailbox_ids is not None and mailbox_id not in visible_mailbox_ids:
raise ForbiddenError("Accesso alla casella non autorizzato")
q = q.where(Message.mailbox_id == mailbox_id)
@@ -278,31 +269,60 @@ async def list_messages(
if is_archived is not None:
q = q.where(Message.is_archived == is_archived)
+ if is_trashed is not None:
+ q = q.where(Message.is_trashed == is_trashed)
+
+ # ── Filtri Conservazione ──────────────────────────────────────────────────
+ if is_pending_conservation is not None:
+ q = q.where(Message.is_pending_conservation == is_pending_conservation)
+
+ if is_conserved is not None:
+ q = q.where(Message.is_conserved == is_conserved)
+
+ # ── Full-text search ──────────────────────────────────────────────────────
if search:
- term = f"%{search}%"
+ from sqlalchemy import case as sa_case
+
+ tsquery = func.websearch_to_tsquery("italian", search)
+ term_like = f"%{search}%"
q = q.where(
or_(
- Message.subject.ilike(term),
- Message.from_address.ilike(term),
- Message.body_text.ilike(term),
+ Message.search_vector.op("@@")(tsquery),
+ Message.search_vector.is_(None) & or_(
+ Message.subject.ilike(term_like),
+ Message.from_address.ilike(term_like),
+ Message.body_text.ilike(term_like),
+ ),
)
)
- # Applica le regole della Virtual Box (AND tra le regole)
+ # ── Filtri data ───────────────────────────────────────────────────────────
+ if date_from:
+ q = q.where(or_(Message.received_at >= date_from, Message.sent_at >= date_from))
+ if date_to:
+ q = q.where(or_(Message.received_at <= date_to, Message.sent_at <= date_to))
+
for rule in vbox_rules:
q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value)
- # Conteggio totale
count_q = select(func.count()).select_from(q.subquery())
total = (await db.execute(count_q)).scalar_one()
- # Ordinamento e paginazione
+ if search:
+ from sqlalchemy import case as sa_case
+
+ tsquery_ord = func.websearch_to_tsquery("italian", search)
+ rank_expr = sa_case(
+ (Message.search_vector.isnot(None), func.ts_rank(Message.search_vector, tsquery_ord)),
+ else_=0.0,
+ )
+ order_clauses = [rank_expr.desc(), Message.received_at.desc().nullslast(), Message.created_at.desc()]
+ else:
+ order_clauses = [Message.received_at.desc().nullslast(), Message.created_at.desc()]
+
q = (
q.options(selectinload(Message.labels))
- .order_by(
- Message.received_at.desc().nullslast(),
- Message.created_at.desc(),
- )
+ .order_by(*order_clauses)
.offset((page - 1) * page_size)
.limit(page_size)
)
@@ -325,15 +345,13 @@ async def bulk_update_messages(
db: DB,
) -> MessageBulkUpdateResponse:
"""
- Aggiorna in blocco i flag operativi (is_starred, is_archived) di più messaggi.
+ Aggiorna in blocco i flag operativi di piu messaggi.
- Restituisce il numero di messaggi aggiornati e la lista aggiornata.
- I messaggi non trovati o non accessibili vengono silenziosamente ignorati.
+ Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
"""
if not data.ids:
return MessageBulkUpdateResponse(updated=0, items=[])
- # Carica tutti i messaggi del tenant
result = await db.execute(
select(Message).where(
Message.id.in_(data.ids),
@@ -342,7 +360,7 @@ async def bulk_update_messages(
)
messages = list(result.scalars().all())
- # Filtra per permessi se non admin
+ # Filtra per permessi di lettura
if not current_user.is_admin:
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
@@ -350,8 +368,26 @@ async def bulk_update_messages(
visible_set = set(visible) if visible else set()
messages = [m for m in messages if m.mailbox_id in visible_set]
+ # Se si tenta di modificare flag di conservazione, verifica can_conserve
+ conservation_change = (
+ data.is_pending_conservation is not None or data.is_conserved is not None
+ )
+ if conservation_change and messages:
+ from app.services.permission_service import PermissionService
+ perm_svc = PermissionService(db)
+ # Verifica per ogni casella unica coinvolta
+ mailbox_ids = {m.mailbox_id for m in messages}
+ for mb_id in mailbox_ids:
+ can = await perm_svc.check_can_conserve(current_user, mb_id)
+ if not can:
+ raise ForbiddenError(
+ "Permesso 'conservazione' non assegnato per questa casella"
+ )
+
now = datetime.now(timezone.utc)
for message in messages:
+ if data.is_read is not None:
+ message.is_read = data.is_read
if data.is_starred is not None:
message.is_starred = data.is_starred
if data.is_archived is not None:
@@ -360,10 +396,27 @@ async def bulk_update_messages(
message.archived_at = now
elif not data.is_archived:
message.archived_at = None
+ if data.is_trashed is not None:
+ message.is_trashed = data.is_trashed
+ if data.is_trashed and not message.trashed_at:
+ message.trashed_at = now
+ elif not data.is_trashed:
+ message.trashed_at = None
+ if data.is_pending_conservation is not None:
+ message.is_pending_conservation = data.is_pending_conservation
+ if data.is_pending_conservation and not message.pending_conservation_at:
+ message.pending_conservation_at = now
+ elif not data.is_pending_conservation:
+ message.pending_conservation_at = None
+ if data.is_conserved is not None:
+ message.is_conserved = data.is_conserved
+ if data.is_conserved and not message.conserved_at:
+ message.conserved_at = now
+ elif not data.is_conserved:
+ message.conserved_at = None
await db.commit()
- # Ricarica i messaggi aggiornati con selectinload per evitare MissingGreenlet sui labels
if messages:
updated_ids = [m.id for m in messages]
refreshed_result = await db.execute(
@@ -398,11 +451,20 @@ async def update_message(
db: DB,
) -> MessageResponse:
"""
- Aggiorna i flag operativi di un messaggio:
- is_read, is_starred, is_archived.
+ Aggiorna i flag operativi di un messaggio.
+
+ Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
"""
message = await _resolve_message(message_id, current_user, db)
+ # Verifica permesso conservazione se necessario
+ if data.is_pending_conservation is not None or data.is_conserved is not None:
+ from app.services.permission_service import PermissionService
+ perm_svc = PermissionService(db)
+ await perm_svc.require_can_conserve(current_user, message.mailbox_id)
+
+ now = datetime.now(timezone.utc)
+
if data.is_read is not None:
message.is_read = data.is_read
if data.is_starred is not None:
@@ -410,12 +472,29 @@ async def update_message(
if data.is_archived is not None:
message.is_archived = data.is_archived
if data.is_archived and not message.archived_at:
- message.archived_at = datetime.now(timezone.utc)
+ message.archived_at = now
elif not data.is_archived:
message.archived_at = None
+ if data.is_trashed is not None:
+ message.is_trashed = data.is_trashed
+ if data.is_trashed and not message.trashed_at:
+ message.trashed_at = now
+ elif not data.is_trashed:
+ message.trashed_at = None
+ if data.is_pending_conservation is not None:
+ message.is_pending_conservation = data.is_pending_conservation
+ if data.is_pending_conservation and not message.pending_conservation_at:
+ message.pending_conservation_at = now
+ elif not data.is_pending_conservation:
+ message.pending_conservation_at = None
+ if data.is_conserved is not None:
+ message.is_conserved = data.is_conserved
+ if data.is_conserved and not message.conserved_at:
+ message.conserved_at = now
+ elif not data.is_conserved:
+ message.conserved_at = None
await db.commit()
- # Re-query con selectinload per evitare MissingGreenlet sui labels
refreshed = await db.execute(
select(Message)
.where(Message.id == message_id)
@@ -450,14 +529,9 @@ async def download_attachment(
current_user: CurrentUser,
db: DB,
) -> StreamingResponse:
- """
- Scarica un allegato direttamente da MinIO.
- Il file viene streamato al client con i header Content-Disposition corretti.
- """
- # Verifica accesso al messaggio
+ """Scarica un allegato direttamente da MinIO."""
await _resolve_message(message_id, current_user, db)
- # Carica allegato
result = await db.execute(
select(Attachment).where(
Attachment.id == attachment_id,
@@ -468,7 +542,6 @@ async def download_attachment(
if not attachment:
raise NotFoundError(f"Allegato {attachment_id} non trovato")
- # Stream da MinIO
try:
from miniopy_async import Minio
@@ -479,7 +552,6 @@ async def download_attachment(
secure=settings.minio_use_ssl,
)
- # storage_path è del tipo "tenant_id/attachments/filename"
storage_path = attachment.storage_path
response = await client.get_object(settings.minio_bucket, storage_path)
@@ -506,17 +578,127 @@ async def download_attachment(
raise NotFoundError("File non disponibile al momento")
+@router.get("/{message_id}/download-package")
+async def download_package(
+ message_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> StreamingResponse:
+ """Scarica un archivio ZIP con tutti i file originali della PEC."""
+ import io
+ import zipfile as _zipfile
+
+ from miniopy_async import Minio
+
+ message = await _resolve_message(message_id, current_user, db)
+
+ client = Minio(
+ endpoint=settings.minio_endpoint,
+ access_key=settings.minio_access_key,
+ secret_key=settings.minio_secret_key,
+ secure=settings.minio_use_ssl,
+ )
+
+ buf = io.BytesIO()
+
+ async def _read_minio(path: str) -> bytes:
+ try:
+ resp = await client.get_object(settings.minio_bucket, path)
+ data = await resp.content.read()
+ resp.close()
+ return data
+ except Exception:
+ return b""
+
+ with _zipfile.ZipFile(buf, mode="w", compression=_zipfile.ZIP_DEFLATED) as zf:
+ att_result = await db.execute(
+ select(Attachment)
+ .where(Attachment.message_id == message.id)
+ .order_by(Attachment.created_at)
+ )
+ main_attachments = list(att_result.scalars().all())
+
+ for att in main_attachments:
+ data = await _read_minio(att.storage_path)
+ if data:
+ zf.writestr(att.filename, data)
+
+ if message.raw_eml_path:
+ data = await _read_minio(message.raw_eml_path)
+ if data:
+ eml_name = message.raw_eml_path.rsplit("/", 1)[-1]
+ if not eml_name.endswith(".eml"):
+ eml_name = "messaggio_originale.eml"
+ existing = {info.filename for info in zf.infolist()}
+ if eml_name not in existing:
+ zf.writestr(eml_name, data)
+
+ if message.direction == "outbound":
+ receipts_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(receipts_result.scalars().all())
+
+ for receipt in receipts:
+ pec_type = receipt.pec_type or "ricevuta"
+ folder = f"ricevute/{pec_type}"
+
+ r_att_result = await db.execute(
+ select(Attachment)
+ .where(Attachment.message_id == receipt.id)
+ .order_by(Attachment.created_at)
+ )
+ r_attachments = list(r_att_result.scalars().all())
+
+ for att in r_attachments:
+ data = await _read_minio(att.storage_path)
+ if data:
+ zip_path = f"{folder}/{att.filename}"
+ existing = {info.filename for info in zf.infolist()}
+ final_path = zip_path
+ counter = 1
+ while final_path in existing:
+ name, _, ext = att.filename.rpartition(".")
+ final_path = f"{folder}/{name}_{counter}.{ext}" if ext else f"{folder}/{att.filename}_{counter}"
+ counter += 1
+ zf.writestr(final_path, data)
+
+ if receipt.raw_eml_path:
+ data = await _read_minio(receipt.raw_eml_path)
+ if data:
+ eml_name = receipt.raw_eml_path.rsplit("/", 1)[-1]
+ if not eml_name.endswith(".eml"):
+ eml_name = f"{pec_type}.eml"
+ zip_path = f"{folder}/{eml_name}"
+ existing = {info.filename for info in zf.infolist()}
+ if zip_path not in existing:
+ zf.writestr(zip_path, data)
+
+ buf.seek(0)
+ zip_bytes = buf.getvalue()
+
+ safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50]
+ zip_filename = f"pec_{safe_subject}.zip"
+
+ return StreamingResponse(
+ iter([zip_bytes]),
+ media_type="application/zip",
+ headers={
+ "Content-Disposition": f'attachment; filename="{zip_filename}"',
+ "Content-Length": str(len(zip_bytes)),
+ },
+ )
+
+
@router.get("/{message_id}/receipts", response_model=list[MessageResponse])
async def list_receipts(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[MessageResponse]:
- """
- Elenca le ricevute associate a un messaggio outbound
- (messaggi con parent_message_id = message_id).
- """
- # Verifica accesso al messaggio padre
+ """Elenca le ricevute associate a un messaggio outbound."""
await _resolve_message(message_id, current_user, db)
result = await db.execute(
@@ -527,3 +709,284 @@ async def list_receipts(
)
receipts = list(result.scalars().all())
return [MessageResponse.model_validate(r) for r in receipts]
+
+
+# ─── Feature 3: Thread/conversazioni ─────────────────────────────────────────
+
+@router.get("/{message_id}/thread", response_model=list[MessageResponse])
+async def get_thread(
+ message_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> list[MessageResponse]:
+ """
+ Restituisce l'intera conversazione (thread) di cui fa parte il messaggio.
+
+ Risale alla radice della conversazione (risalendo i parent_message_id),
+ poi carica tutti i messaggi del thread ordinati cronologicamente.
+ Esclude le ricevute PEC (pec_type != posta_certificata).
+ """
+ message = await _resolve_message(message_id, current_user, db)
+
+ # Risale alla radice del thread
+ root_id = message.id
+ visited: set[uuid.UUID] = {message.id}
+ current = message
+ while current.parent_message_id and current.parent_message_id not in visited:
+ visited.add(current.parent_message_id)
+ parent_result = await db.execute(
+ select(Message).where(
+ Message.id == current.parent_message_id,
+ Message.tenant_id == current_user.tenant_id,
+ )
+ )
+ parent = parent_result.scalar_one_or_none()
+ if not parent:
+ break
+ current = parent
+ root_id = current.id
+
+ # Carica ricorsivamente tutti i messaggi del thread dalla radice
+ # Limita a posta_certificata (esclude accettazioni, consegne, ecc.)
+ thread_messages: list[Message] = []
+
+ async def _collect(msg_id: uuid.UUID) -> None:
+ result = await db.execute(
+ select(Message)
+ .where(
+ Message.id == msg_id,
+ Message.tenant_id == current_user.tenant_id,
+ Message.pec_type == "posta_certificata",
+ )
+ .options(selectinload(Message.labels))
+ )
+ msg = result.scalar_one_or_none()
+ if msg:
+ thread_messages.append(msg)
+
+ children_result = await db.execute(
+ select(Message)
+ .where(
+ Message.parent_message_id == msg_id,
+ Message.tenant_id == current_user.tenant_id,
+ Message.pec_type == "posta_certificata",
+ )
+ .options(selectinload(Message.labels))
+ .order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
+ )
+ children = list(children_result.scalars().all())
+ for child in children:
+ await _collect(child.id)
+
+ await _collect(root_id)
+
+ # Ordina cronologicamente
+ thread_messages.sort(
+ key=lambda m: m.received_at or m.sent_at or m.created_at
+ )
+
+ return [MessageResponse.model_validate(m) for m in thread_messages]
+
+
+# ─── Feature 7: Preview allegati (presigned URL) ──────────────────────────────
+
+@router.get("/{message_id}/attachments/{attachment_id}/preview-url")
+async def get_attachment_preview_url(
+ message_id: uuid.UUID,
+ attachment_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> dict:
+ """
+ Restituisce una presigned URL MinIO per la preview inline dell'allegato.
+
+ La URL e' valida per 5 minuti. Supporta PDF e immagini.
+ Per altri tipi di file reindirizza al download normale.
+ """
+ await _resolve_message(message_id, current_user, db)
+
+ result = await db.execute(
+ select(Attachment).where(
+ Attachment.id == attachment_id,
+ Attachment.message_id == message_id,
+ )
+ )
+ attachment = result.scalar_one_or_none()
+ if not attachment:
+ raise NotFoundError(f"Allegato {attachment_id} non trovato")
+
+ content_type = attachment.content_type or "application/octet-stream"
+ previewable = (
+ content_type.startswith("image/") or
+ content_type == "application/pdf"
+ )
+
+ if not previewable:
+ return {
+ "previewable": False,
+ "content_type": content_type,
+ "filename": attachment.filename,
+ }
+
+ try:
+ from datetime import timedelta as _timedelta
+ from miniopy_async import Minio
+
+ client = Minio(
+ endpoint=settings.minio_endpoint,
+ access_key=settings.minio_access_key,
+ secret_key=settings.minio_secret_key,
+ secure=settings.minio_use_ssl,
+ )
+ presigned_url = await client.presigned_get_object(
+ settings.minio_bucket,
+ attachment.storage_path,
+ expires=_timedelta(minutes=5),
+ )
+ return {
+ "previewable": True,
+ "content_type": content_type,
+ "filename": attachment.filename,
+ "url": presigned_url,
+ }
+ except Exception as e:
+ from app.core.logging import get_logger
+ logger = get_logger(__name__)
+ logger.error(f"Errore generazione presigned URL allegato {attachment_id}: {e}")
+ return {
+ "previewable": False,
+ "content_type": content_type,
+ "filename": attachment.filename,
+ }
+
+
+# ─── Feature 8: Stampa/export HTML ────────────────────────────────────────────
+
+@router.get("/{message_id}/print")
+async def print_message(
+ message_id: uuid.UUID,
+ current_user: CurrentUser,
+ db: DB,
+) -> "HTMLResponse":
+ """
+ Restituisce una rappresentazione HTML ottimizzata per la stampa del messaggio.
+
+ Include: intestazione, corpo, lista allegati, albero ricevute.
+ Pronto per window.print() o salvataggio come PDF tramite browser.
+ """
+ from fastapi.responses import HTMLResponse
+
+ message = await _resolve_message(message_id, current_user, db)
+
+ att_result = await db.execute(
+ select(Attachment).where(Attachment.message_id == message.id).order_by(Attachment.created_at)
+ )
+ attachments = list(att_result.scalars().all())
+
+ receipts_html = ""
+ if message.direction == "outbound":
+ rec_result = await db.execute(
+ select(Message)
+ .where(Message.parent_message_id == message.id)
+ .order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
+ )
+ receipts = list(rec_result.scalars().all())
+
+ PEC_TYPE_LABELS = {
+ "accettazione": "Accettazione",
+ "avvenuta_consegna": "Avvenuta consegna",
+ "non_accettazione": "Non accettazione",
+ "mancata_consegna": "Mancata consegna",
+ "errore_consegna": "Errore consegna",
+ "presa_in_carico": "Presa in carico",
+ "preavviso_mancata_consegna": "Preavviso mancata consegna",
+ "rilevazione_virus": "Rilevazione virus",
+ }
+
+ receipt_rows = ""
+ for r in receipts:
+ label = PEC_TYPE_LABELS.get(r.pec_type, r.pec_type)
+ date_str = ""
+ if r.received_at:
+ date_str = r.received_at.strftime("%d/%m/%Y %H:%M:%S")
+ receipt_rows += f"
{label} {date_str} "
+
+ if receipt_rows:
+ receipts_html = f"""
+
+ Tracciamento invio
+
+ Tipo ricevuta Data
+ {receipt_rows}
+
+ """
+
+ att_rows = ""
+ for att in attachments:
+ size_str = f"{att.size_bytes:,} byte" if att.size_bytes else ""
+ att_rows += f"{att.filename} ({att.content_type or ''}) {size_str} "
+
+ att_html = f"Allegati ({len(attachments)}) " if attachments else ""
+
+ from_label = "Da" if message.direction == "inbound" else "A"
+ from_val = message.from_address if message.direction == "inbound" else ", ".join(message.to_addresses or [])
+ date_val = ""
+ date_field = message.received_at or message.sent_at or message.created_at
+ if date_field:
+ date_val = date_field.strftime("%d/%m/%Y %H:%M:%S")
+
+ body_html = ""
+ if message.body_html:
+ body_html = f"{message.body_html}
"
+ elif message.body_text:
+ body_html = f"{message.body_text} "
+
+ deadline_html = ""
+ if message.deadline_at:
+ dl_str = message.deadline_at.strftime("%d/%m/%Y %H:%M")
+ deadline_html = f"Scadenza: {dl_str}
"
+ if message.deadline_note:
+ deadline_html += f"Nota scadenza: {message.deadline_note}
"
+
+ html = f"""
+
+
+
+PEC - {message.subject or '(nessun oggetto)'}
+
+
+
+{message.subject or '(nessun oggetto)'}
+
+{deadline_html}
+{body_html}
+{att_html}
+{receipts_html}
+
+ Documento generato da PEChub il {date_val} – ID messaggio: {message.id}
+
+
+"""
+
+ return HTMLResponse(content=html, media_type="text/html; charset=utf-8")
diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py
new file mode 100644
index 0000000..1d60a68
--- /dev/null
+++ b/backend/app/api/v1/reports.py
@@ -0,0 +1,114 @@
+"""
+Router Reports – Dashboard e Reportistica (Fase 7).
+
+Endpoint:
+ GET /reports/summary – KPI + serie storica + breakdown caselle
+ GET /reports/export – export CSV o PDF
+
+Permessi:
+ - Tutti gli utenti autenticati possono accedere.
+ - Admin e super_admin: vedono tutto il tenant.
+ - Operator/Supervisor/Readonly: vedono solo le caselle su cui hanno can_read.
+"""
+
+import uuid
+from datetime import datetime
+from typing import Optional
+
+from fastapi import APIRouter, Query
+from fastapi.responses import Response
+
+from app.dependencies import CurrentUser, DB
+from app.schemas.reports import ReportSummaryResponse
+from app.services.report_service import ReportService
+
+router = APIRouter(prefix="/reports", tags=["Reports"])
+
+
+async def _get_visible_ids(user, db) -> Optional[list[uuid.UUID]]:
+ """Restituisce None per admin (nessun filtro), lista per non-admin."""
+ if user.is_admin:
+ return None
+ from app.services.permission_service import PermissionService
+ svc = PermissionService(db)
+ return await svc.get_visible_mailboxes(user)
+
+
+# ─── GET /reports/summary ─────────────────────────────────────────────────────
+
+@router.get("/summary", response_model=ReportSummaryResponse)
+async def get_report_summary(
+ current_user: CurrentUser,
+ db: DB,
+ days: int = Query(7, ge=1, le=365, description="Periodo in giorni per la serie storica"),
+) -> ReportSummaryResponse:
+ """
+ Restituisce il riepilogo completo per la dashboard:
+ - KPI (PEC ricevute/inviate oggi, anomalie, tasso consegna, ...)
+ - Serie storica giornaliera per il grafico a barre
+ - Distribuzione stati outbound per il grafico a torta
+ - Statistiche per casella
+ """
+ visible = await _get_visible_ids(current_user, db)
+ svc = ReportService(db)
+ return await svc.get_summary(
+ tenant_id=current_user.tenant_id,
+ period_days=days,
+ visible_mailbox_ids=visible,
+ )
+
+
+# ─── GET /reports/export ──────────────────────────────────────────────────────
+
+@router.get("/export")
+async def export_report(
+ current_user: CurrentUser,
+ db: DB,
+ format: str = Query("csv", pattern="^(csv|pdf)$", description="Formato: csv o pdf"),
+ date_from: Optional[datetime] = Query(None, description="Data inizio (ISO 8601)"),
+ date_to: Optional[datetime] = Query(None, description="Data fine (ISO 8601)"),
+ mailbox_id: Optional[uuid.UUID] = Query(None, description="Filtra per casella specifica"),
+) -> Response:
+ """
+ Esporta i dati in formato CSV o PDF.
+
+ - CSV: lista completa dei messaggi del periodo con tutti i metadati.
+ - PDF: riepilogo KPI + tabella caselle (generato con reportlab).
+ """
+ visible = await _get_visible_ids(current_user, db)
+ svc = ReportService(db)
+
+ now_str = datetime.now().strftime("%Y%m%d_%H%M%S")
+
+ if format == "csv":
+ data = await svc.export_csv(
+ tenant_id=current_user.tenant_id,
+ date_from=date_from,
+ date_to=date_to,
+ mailbox_id=mailbox_id,
+ visible_mailbox_ids=visible,
+ )
+ return Response(
+ content=data,
+ media_type="text/csv; charset=utf-8-sig",
+ headers={
+ "Content-Disposition": f'attachment; filename="pechub_report_{now_str}.csv"',
+ "Content-Length": str(len(data)),
+ },
+ )
+
+ # PDF
+ data = await svc.export_pdf(
+ tenant_id=current_user.tenant_id,
+ date_from=date_from,
+ date_to=date_to,
+ visible_mailbox_ids=visible,
+ )
+ return Response(
+ content=data,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f'attachment; filename="pechub_report_{now_str}.pdf"',
+ "Content-Length": str(len(data)),
+ },
+ )
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/settings.py b/backend/app/api/v1/settings.py
index 3bf9691..b3501ba 100644
--- a/backend/app/api/v1/settings.py
+++ b/backend/app/api/v1/settings.py
@@ -1,23 +1,57 @@
"""
-Router Impostazioni Tenant (Fase 6).
+Router Impostazioni Tenant.
-Endpoint:
- GET /settings → legge le impostazioni del tenant corrente (admin)
- PUT /settings → aggiorna le impostazioni del tenant corrente (admin)
+Endpoint esistenti:
+ GET /settings -> legge le impostazioni del tenant corrente (admin)
+ PUT /settings -> aggiorna le impostazioni del tenant corrente (admin)
-Solo gli admin e super_admin possono accedere.
-La sezione "archiviazione" gestisce il toggle mock/produzione per il
-conservatore AgID (Fase 6 – Archiviazione Sostitutiva).
+Endpoint indicizzazione full-text (Fase 8):
+ GET /settings/indexing/stats -> statistiche copertura indicizzazione
+ GET /settings/indexing/status -> stato job reindex in corso
+ POST /settings/indexing/reindex -> avvia reindex (full o differential)
+ DELETE /settings/indexing/reindex -> cancella job in corso
+
+Solo admin e super_admin possono accedere.
+Il super_admin puo' operare su qualsiasi tenant tramite query param ?tenant_id=.
"""
-from fastapi import APIRouter
+import uuid
+from typing import Annotated, Optional
+from fastapi import APIRouter, HTTPException, Query, status
+
+from app.config import get_settings as get_app_settings
from app.dependencies import AdminUser, DB
-from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate
+from app.schemas.tenant_settings import (
+ IndexingJobStatus,
+ IndexingStats,
+ StartReindexRequest,
+ StartRescanRequest,
+ TenantSettingsResponse,
+ TenantSettingsUpdate,
+)
+from app.services.indexing_service import IndexingService
from app.services.tenant_settings_service import TenantSettingsService
router = APIRouter(prefix="/settings", tags=["Impostazioni"])
+# ─── Helper tenant_id resolution ─────────────────────────────────────────────
+
+def _resolve_tenant_id(
+ current_user: AdminUser,
+ tenant_id_param: Optional[uuid.UUID] = None,
+) -> uuid.UUID:
+ """
+ Risolve il tenant_id da usare per l'operazione.
+ - super_admin: puo' passare un tenant_id arbitrario
+ - admin: usa sempre il proprio tenant_id (tenant_id_param ignorato)
+ """
+ if current_user.role == "super_admin" and tenant_id_param is not None:
+ return tenant_id_param
+ return current_user.tenant_id
+
+
+# ─── Impostazioni generali ────────────────────────────────────────────────────
@router.get(
"",
@@ -25,7 +59,7 @@ router = APIRouter(prefix="/settings", tags=["Impostazioni"])
summary="Legge le impostazioni del tenant",
description=(
"Restituisce la configurazione operativa del tenant: "
- "modalità archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
+ "modalita' archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
),
)
async def get_settings(
@@ -44,7 +78,7 @@ async def get_settings(
description=(
"Aggiorna la configurazione operativa del tenant. "
"Tutti i campi sono opzionali (semantica PATCH). "
- "Il passaggio a modalità 'production' richiede un endpoint conservatore configurato."
+ "Il passaggio a modalita' 'production' richiede un endpoint conservatore configurato."
),
)
async def update_settings(
@@ -57,3 +91,247 @@ async def update_settings(
await db.commit()
await db.refresh(settings)
return TenantSettingsService.to_response(settings)
+
+
+# ─── Indicizzazione full-text ─────────────────────────────────────────────────
+
+@router.get(
+ "/indexing/stats",
+ response_model=IndexingStats,
+ summary="Statistiche indicizzazione full-text",
+ description=(
+ "Restituisce il numero di messaggi totali, indicizzati e non indicizzati "
+ "per il tenant, con percentuale di copertura. "
+ "Include anche le statistiche sugli allegati PDF/DOCX con testo estratto. "
+ "Il super_admin puo' specificare ?tenant_id= per un tenant arbitrario."
+ ),
+)
+async def get_indexing_stats(
+ current_user: AdminUser,
+ db: DB,
+ tenant_id: Optional[uuid.UUID] = Query(
+ default=None,
+ description="(solo super_admin) UUID del tenant su cui operare",
+ ),
+) -> IndexingStats:
+ target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
+ service = IndexingService(db)
+ stats = await service.get_stats(target_tenant_id)
+ return IndexingStats(**stats)
+
+
+@router.get(
+ "/indexing/status",
+ response_model=IndexingJobStatus,
+ summary="Stato job indicizzazione in corso",
+ description=(
+ "Restituisce lo stato del job di reindex per il tenant: "
+ "idle, running (con progresso), completed, failed o cancelled. "
+ "Se il job e' running da piu' di 2 ore, il flag is_stale e' True."
+ ),
+)
+async def get_indexing_status(
+ current_user: AdminUser,
+ db: DB,
+ tenant_id: Optional[uuid.UUID] = Query(
+ default=None,
+ description="(solo super_admin) UUID del tenant su cui operare",
+ ),
+) -> IndexingJobStatus:
+ target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
+ app_settings = get_app_settings()
+ status_data = await IndexingService.get_job_status(
+ target_tenant_id, app_settings.redis_url
+ )
+ return IndexingJobStatus(**status_data)
+
+
+@router.post(
+ "/indexing/reindex",
+ response_model=IndexingJobStatus,
+ status_code=status.HTTP_202_ACCEPTED,
+ summary="Avvia job di reindex",
+ description=(
+ "Avvia un job di reindex full-text in background. "
+ "mode='differential' indicizza solo i messaggi con search_vector NULL (piu' veloce). "
+ "mode='full' riscrive il vettore di tutti i messaggi del tenant. "
+ "Restituisce 409 se un job e' gia' in corso."
+ ),
+)
+async def start_reindex(
+ body: StartReindexRequest,
+ current_user: AdminUser,
+ db: DB,
+ tenant_id: Optional[uuid.UUID] = Query(
+ default=None,
+ description="(solo super_admin) UUID del tenant su cui operare",
+ ),
+) -> IndexingJobStatus:
+ target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
+ app_settings = get_app_settings()
+
+ try:
+ await IndexingService.start_reindex(
+ tenant_id=target_tenant_id,
+ mode=body.mode,
+ started_by_email=current_user.email,
+ redis_url=app_settings.redis_url,
+ db_url=app_settings.database_url,
+ )
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=str(exc),
+ )
+
+ # Ritorna lo stato appena creato
+ status_data = await IndexingService.get_job_status(
+ target_tenant_id, app_settings.redis_url
+ )
+ return IndexingJobStatus(**status_data)
+
+
+@router.delete(
+ "/indexing/reindex",
+ response_model=IndexingJobStatus,
+ summary="Cancella job di reindex in corso",
+ description=(
+ "Invia il segnale di cancellazione al job di reindex in corso. "
+ "Il job si fermera' alla fine del batch corrente (max qualche secondo). "
+ "Se non c'e' nessun job in corso, ritorna 404."
+ ),
+)
+async def cancel_reindex(
+ current_user: AdminUser,
+ db: DB,
+ tenant_id: Optional[uuid.UUID] = Query(
+ default=None,
+ description="(solo super_admin) UUID del tenant su cui operare",
+ ),
+) -> IndexingJobStatus:
+ target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
+ app_settings = get_app_settings()
+
+ cancelled = await IndexingService.cancel_reindex(
+ target_tenant_id, app_settings.redis_url
+ )
+
+ if not cancelled:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Nessun job di reindex in corso per questo tenant",
+ )
+
+ status_data = await IndexingService.get_job_status(
+ target_tenant_id, app_settings.redis_url
+ )
+ return IndexingJobStatus(**status_data)
+
+
+# ─── Scansione allegati ───────────────────────────────────────────────────────
+
+@router.get(
+ "/indexing/rescan-status",
+ response_model=IndexingJobStatus,
+ summary="Stato job scansione allegati in corso",
+ description=(
+ "Restituisce lo stato del job di scansione allegati per il tenant: "
+ "idle, running (con progresso), completed, failed o cancelled."
+ ),
+)
+async def get_rescan_status(
+ current_user: AdminUser,
+ db: DB,
+ tenant_id: Optional[uuid.UUID] = Query(
+ default=None,
+ description="(solo super_admin) UUID del tenant su cui operare",
+ ),
+) -> IndexingJobStatus:
+ target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
+ app_settings = get_app_settings()
+ status_data = await IndexingService.get_rescan_status(
+ target_tenant_id, app_settings.redis_url
+ )
+ return IndexingJobStatus(**status_data)
+
+
+@router.post(
+ "/indexing/rescan",
+ response_model=IndexingJobStatus,
+ status_code=status.HTTP_202_ACCEPTED,
+ summary="Avvia job di scansione allegati",
+ description=(
+ "Avvia un job di scansione allegati in background. "
+ "force=false (default): estrae il testo solo dagli allegati non ancora elaborati. "
+ "force=true: ri-estrae il testo da tutti gli allegati del tenant. "
+ "Al termine di ogni batch aggiorna anche il search_vector dei messaggi interessati. "
+ "Restituisce 409 se un job di scansione o reindex e' gia' in corso."
+ ),
+)
+async def start_rescan(
+ body: StartRescanRequest,
+ current_user: AdminUser,
+ db: DB,
+ tenant_id: Optional[uuid.UUID] = Query(
+ default=None,
+ description="(solo super_admin) UUID del tenant su cui operare",
+ ),
+) -> IndexingJobStatus:
+ target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
+ app_settings = get_app_settings()
+
+ try:
+ await IndexingService.start_rescan(
+ tenant_id=target_tenant_id,
+ started_by_email=current_user.email,
+ redis_url=app_settings.redis_url,
+ db_url=app_settings.database_url,
+ force=body.force,
+ )
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=str(exc),
+ )
+
+ status_data = await IndexingService.get_rescan_status(
+ target_tenant_id, app_settings.redis_url
+ )
+ return IndexingJobStatus(**status_data)
+
+
+@router.delete(
+ "/indexing/rescan",
+ response_model=IndexingJobStatus,
+ summary="Cancella job di scansione allegati in corso",
+ description=(
+ "Invia il segnale di cancellazione al job di scansione allegati in corso. "
+ "Il job si fermera' alla fine del batch corrente. "
+ "Se non c'e' nessun job in corso, ritorna 404."
+ ),
+)
+async def cancel_rescan(
+ current_user: AdminUser,
+ db: DB,
+ tenant_id: Optional[uuid.UUID] = Query(
+ default=None,
+ description="(solo super_admin) UUID del tenant su cui operare",
+ ),
+) -> IndexingJobStatus:
+ target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
+ app_settings = get_app_settings()
+
+ cancelled = await IndexingService.cancel_rescan(
+ target_tenant_id, app_settings.redis_url
+ )
+
+ if not cancelled:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Nessun job di scansione allegati in corso per questo tenant",
+ )
+
+ status_data = await IndexingService.get_rescan_status(
+ target_tenant_id, app_settings.redis_url
+ )
+ return IndexingJobStatus(**status_data)
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/dependencies.py b/backend/app/dependencies.py
index 771960f..f51268b 100644
--- a/backend/app/dependencies.py
+++ b/backend/app/dependencies.py
@@ -153,6 +153,20 @@ async def require_super_admin(
return current_user
+async def require_supervisor_or_admin(
+ current_user: Annotated[User, Depends(get_current_user)],
+) -> User:
+ """
+ Richiede ruolo supervisor, admin o super_admin.
+
+ Il supervisor ha accesso in lettura implicito a tutte le caselle del tenant
+ ma non puo' gestire la configurazione (caselle, utenti, permessi, impostazioni).
+ """
+ if not current_user.is_supervisor_or_admin:
+ raise ForbiddenError("Richiesto ruolo supervisore o amministratore")
+ return current_user
+
+
# ─── Protezione endpoint admin con X-Admin-Key header ─────────────────────────
async def verify_admin_key(
@@ -176,4 +190,5 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
CurrentTenant = Annotated[Tenant, Depends(get_current_tenant)]
AdminUser = Annotated[User, Depends(require_admin)]
SuperAdminUser = Annotated[User, Depends(require_super_admin)]
+SupervisorOrAdminUser = Annotated[User, Depends(require_supervisor_or_admin)]
DB = Annotated[AsyncSession, Depends(get_db)]
diff --git a/backend/app/main.py b/backend/app/main.py
index a300e83..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 auth, labels, mailboxes, messages, notifications, permissions, 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
@@ -96,6 +96,12 @@ app.include_router(virtual_boxes.router, prefix=API_PREFIX)
app.include_router(notifications.router, prefix=API_PREFIX)
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 3af1989..e3fba06 100644
--- a/backend/app/models/message.py
+++ b/backend/app/models/message.py
@@ -4,6 +4,7 @@ Modelli Message, Attachment, SendJob.
import uuid
from datetime import datetime
+from typing import Any
from sqlalchemy import (
ARRAY,
@@ -18,7 +19,7 @@ from sqlalchemy import (
Text,
func,
)
-from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.dialects.postgresql import TSVECTOR, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -91,9 +92,24 @@ class Message(Base):
is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ 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)
+ is_conserved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ conserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
+ # Full-text search vector (aggiornato da trigger DB + worker per allegati)
+ search_vector: Mapped[Any | None] = mapped_column(TSVECTOR(), nullable=True)
+
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -124,6 +140,7 @@ class Message(Base):
postgresql_where="parent_message_id IS NOT NULL",
),
Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"),
+ Index("idx_messages_fts", "search_vector", postgresql_using="gin"),
)
def __repr__(self) -> str:
@@ -147,6 +164,8 @@ class Attachment(Base):
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ # Testo estratto dal worker (solo PDF e DOCX) per la ricerca full-text
+ extracted_text: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -179,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/permission.py b/backend/app/models/permission.py
index 237dac9..ce0cf3c 100644
--- a/backend/app/models/permission.py
+++ b/backend/app/models/permission.py
@@ -41,6 +41,7 @@ class MailboxPermission(Base):
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_manage: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ can_conserve: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
granted_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
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/models/user.py b/backend/app/models/user.py
index a208ff8..f3104fe 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -86,6 +86,16 @@ class User(Base):
def is_super_admin(self) -> bool:
return self.role == "super_admin"
+ @property
+ def is_supervisor(self) -> bool:
+ """Ruolo supervisor: lettura implicita su tutte le caselle, senza poteri di gestione."""
+ return self.role == "supervisor"
+
+ @property
+ def is_supervisor_or_admin(self) -> bool:
+ """True per super_admin, admin e supervisor (accesso in lettura a tutto il tenant)."""
+ return self.role in ("super_admin", "admin", "supervisor")
+
def __repr__(self) -> str:
return f""
diff --git a/backend/app/notifications/__init__.py b/backend/app/notifications/__init__.py
index 089f831..2a45687 100644
--- a/backend/app/notifications/__init__.py
+++ b/backend/app/notifications/__init__.py
@@ -1 +1 @@
-# Modulo notifiche – mittenti multi-canale
+-# Modulo notifiche – mittenti multi-canale
diff --git a/backend/app/notifications/email_smtp.py b/backend/app/notifications/email_smtp.py
new file mode 100644
index 0000000..de73c43
--- /dev/null
+++ b/backend/app/notifications/email_smtp.py
@@ -0,0 +1,161 @@
+"""
+Email SMTP sender – invio notifiche via SMTP con TLS/SSL o STARTTLS.
+
+Config non sensibile (config):
+ {
+ "smtp_host": "smtp.example.com",
+ "smtp_port": 465,
+ "smtp_use_tls": true, # SSL/TLS diretto (porta 465)
+ "smtp_use_starttls": false, # STARTTLS (porta 587) – alternativo a use_tls
+ "from_email": "noreply@example.com",
+ "from_name": "PEChub Notifiche",
+ "to_email": "destinatario@example.com"
+ }
+
+Config sensibile (config_enc → config_secret):
+ { "smtp_password": "..." }
+
+Dipendenza: aiosmtplib (gia' in backend/pyproject.toml)
+"""
+
+from datetime import datetime
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+import aiosmtplib
+
+DEFAULT_TIMEOUT = 15.0
+
+
+class EmailSMTPError(Exception):
+ """Errore durante l'invio di un'email di notifica."""
+
+ def __init__(self, message: str, smtp_code: int | None = None):
+ super().__init__(message)
+ self.smtp_code = smtp_code
+
+
+async def send_email_notification(
+ smtp_host: str,
+ smtp_port: int,
+ smtp_user: str,
+ smtp_password: str,
+ from_email: str,
+ to_email: str,
+ subject: str,
+ body_text: str,
+ body_html: str | None = None,
+ from_name: str = "PEChub Notifiche",
+ use_tls: bool = True,
+ use_starttls: bool = False,
+ timeout: float = DEFAULT_TIMEOUT,
+) -> None:
+ """
+ Invia un'email di notifica via SMTP.
+
+ Args:
+ smtp_host: host SMTP
+ smtp_port: porta SMTP
+ smtp_user: username autenticazione
+ smtp_password: password autenticazione
+ from_email: indirizzo mittente
+ to_email: indirizzo destinatario
+ subject: oggetto email
+ body_text: testo plain
+ body_html: testo HTML (opzionale)
+ from_name: nome visualizzato mittente
+ use_tls: usa SSL/TLS diretto (porta 465)
+ use_starttls: usa STARTTLS (porta 587) – alternativo a use_tls
+ timeout: timeout connessione in secondi
+
+ Raises:
+ EmailSMTPError: in caso di errori di autenticazione, connessione o invio
+ """
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = subject
+ msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
+ msg["To"] = to_email
+ msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
+ msg["X-Mailer"] = "PEChub/1.0"
+
+ msg.attach(MIMEText(body_text, "plain", "utf-8"))
+ if body_html:
+ msg.attach(MIMEText(body_html, "html", "utf-8"))
+
+ try:
+ await aiosmtplib.send(
+ msg,
+ hostname=smtp_host,
+ port=smtp_port,
+ username=smtp_user,
+ password=smtp_password,
+ use_tls=use_tls,
+ start_tls=use_starttls,
+ timeout=timeout,
+ )
+ except aiosmtplib.SMTPAuthenticationError as exc:
+ raise EmailSMTPError(
+ f"Autenticazione SMTP fallita per {smtp_user}@{smtp_host}: {exc}",
+ smtp_code=535,
+ ) from exc
+ except aiosmtplib.SMTPConnectError as exc:
+ raise EmailSMTPError(
+ f"Connessione SMTP fallita a {smtp_host}:{smtp_port}: {exc}"
+ ) from exc
+ except aiosmtplib.SMTPServerDisconnected as exc:
+ raise EmailSMTPError(
+ f"Server SMTP {smtp_host} ha chiuso la connessione: {exc}"
+ ) from exc
+ except aiosmtplib.SMTPException as exc:
+ raise EmailSMTPError(f"Errore SMTP: {exc}") from exc
+ except Exception as exc:
+ raise EmailSMTPError(f"Errore invio email: {exc}") from exc
+
+
+async def send_test_email(
+ smtp_host: str,
+ smtp_port: int,
+ smtp_user: str,
+ smtp_password: str,
+ from_email: str,
+ to_email: str,
+ channel_name: str = "PEChub",
+ from_name: str = "PEChub Notifiche",
+ use_tls: bool = True,
+ use_starttls: bool = False,
+) -> None:
+ """
+ Invia un'email di test per verificare la configurazione del canale.
+
+ Raises:
+ EmailSMTPError: se la connessione o l'autenticazione falliscono
+ """
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ subject = f"[PEChub] Test canale email: {channel_name}"
+ body_text = (
+ f"PEChub – Test canale Email\n\n"
+ f"Il canale '{channel_name}' e' configurato correttamente.\n\n"
+ f"Data/ora: {ts}\n"
+ f"Destinatario: {to_email}"
+ )
+ body_html = (
+ f"PEChub – Test canale Email "
+ f"Il canale {channel_name} e' configurato correttamente.
"
+ f"Data/ora: {ts} "
+ f"Destinatario: {to_email}
"
+ f"Inviato da PEChub Notification Engine
"
+ )
+ await send_email_notification(
+ smtp_host=smtp_host,
+ smtp_port=smtp_port,
+ smtp_user=smtp_user,
+ smtp_password=smtp_password,
+ from_email=from_email,
+ to_email=to_email,
+ subject=subject,
+ body_text=body_text,
+ body_html=body_html,
+ from_name=from_name,
+ use_tls=use_tls,
+ use_starttls=use_starttls,
+ )
diff --git a/backend/app/notifications/webhook.py b/backend/app/notifications/webhook.py
new file mode 100644
index 0000000..c9e4db8
--- /dev/null
+++ b/backend/app/notifications/webhook.py
@@ -0,0 +1,125 @@
+"""
+Webhook sender – POST HTTP con firma HMAC-SHA256.
+
+Config non sensibile (config):
+ { "url": "https://...", "content_type": "application/json" }
+
+Config sensibile (config_enc → config_secret):
+ { "webhook_secret": "..." } # opzionale – usato per firma HMAC
+
+Header inviati:
+ Content-Type: application/json
+ X-PEChub-Event: {event_type}
+ X-Hub-Signature-256: sha256={hex} (solo se webhook_secret configurato)
+ X-Delivery: {uuid}
+ User-Agent: PEChub-Webhook/1.0
+"""
+
+import hashlib
+import hmac
+import json
+import uuid as uuid_mod
+from datetime import datetime
+
+import httpx
+
+DEFAULT_TIMEOUT = 10.0
+
+
+class WebhookError(Exception):
+ """Errore durante l'invio di una notifica webhook."""
+
+ def __init__(self, message: str, http_status: int | None = None):
+ super().__init__(message)
+ self.http_status = http_status
+
+
+async def send_webhook(
+ url: str,
+ payload: dict,
+ event_type: str = "new_message",
+ webhook_secret: str | None = None,
+ timeout: float = DEFAULT_TIMEOUT,
+) -> dict:
+ """
+ Invia un payload JSON a un webhook URL.
+
+ Args:
+ url: URL destinatario del webhook
+ payload: dict serializzato come JSON nel body
+ event_type: valore dell'header X-PEChub-Event
+ webhook_secret: segreto per firma HMAC-SHA256 (opzionale)
+ timeout: timeout HTTP in secondi
+
+ Returns:
+ dict con http_status, response_text, delivery_id
+
+ Raises:
+ WebhookError: in caso di timeout, errore di rete o HTTP >= 400
+ """
+ body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
+ delivery_id = str(uuid_mod.uuid4())
+
+ headers = {
+ "Content-Type": "application/json",
+ "X-PEChub-Event": event_type,
+ "X-Delivery": delivery_id,
+ "User-Agent": "PEChub-Webhook/1.0",
+ }
+
+ if webhook_secret:
+ sig = hmac.new(
+ webhook_secret.encode("utf-8"),
+ body,
+ hashlib.sha256,
+ ).hexdigest()
+ headers["X-Hub-Signature-256"] = f"sha256={sig}"
+
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ try:
+ response = await client.post(url, content=body, headers=headers)
+ except httpx.TimeoutException as exc:
+ raise WebhookError(
+ f"Timeout webhook dopo {timeout}s"
+ ) from exc
+ except httpx.RequestError as exc:
+ raise WebhookError(f"Errore di rete webhook: {exc}") from exc
+
+ if response.status_code >= 400:
+ raise WebhookError(
+ f"Webhook ha risposto con HTTP {response.status_code}: "
+ f"{response.text[:200]}",
+ http_status=response.status_code,
+ )
+
+ return {
+ "http_status": response.status_code,
+ "response_text": response.text[:500],
+ "delivery_id": delivery_id,
+ }
+
+
+async def send_test_webhook(
+ url: str,
+ webhook_secret: str | None = None,
+ channel_name: str = "PEChub",
+) -> dict:
+ """Invia un payload di test al webhook per verificare la configurazione."""
+ payload = {
+ "event": "test",
+ "channel": channel_name,
+ "timestamp": datetime.now().isoformat(),
+ "message": "Notifica di test da PEChub",
+ "data": {
+ "subject": "[TEST] PEC di prova",
+ "from_address": "test@pec.example.it",
+ "pec_type": "posta_certificata",
+ "direction": "inbound",
+ },
+ }
+ return await send_webhook(
+ url=url,
+ payload=payload,
+ event_type="test",
+ webhook_secret=webhook_secret,
+ )
diff --git a/backend/app/notifications/whatsapp.py b/backend/app/notifications/whatsapp.py
new file mode 100644
index 0000000..2be0e0e
--- /dev/null
+++ b/backend/app/notifications/whatsapp.py
@@ -0,0 +1,125 @@
+"""
+WhatsApp sender – Meta Cloud API v18.
+
+Config non sensibile (config):
+ {
+ "phone_number_id": "123456789", # ID numero mittente Meta Business
+ "to_phone": "+393331234567" # numero destinatario con prefisso
+ }
+
+Config sensibile (config_enc → config_secret):
+ { "access_token": "EAABs..." } # Meta Graph API token
+
+API endpoint:
+ POST https://graph.facebook.com/v18.0/{phone_number_id}/messages
+
+Nota: richiede un account Meta Business verificato con WhatsApp Business API.
+"""
+
+import httpx
+
+META_GRAPH_API_URL = "https://graph.facebook.com/v18.0"
+DEFAULT_TIMEOUT = 10.0
+
+
+class WhatsAppError(Exception):
+ """Errore durante l'invio di un messaggio WhatsApp."""
+
+ def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
+ super().__init__(message)
+ self.http_status = http_status
+ self.api_code = api_code
+
+
+async def send_whatsapp_message(
+ phone_number_id: str,
+ to_phone: str,
+ text: str,
+ access_token: str,
+ timeout: float = DEFAULT_TIMEOUT,
+) -> dict:
+ """
+ Invia un messaggio di testo WhatsApp via Meta Cloud API.
+
+ Args:
+ phone_number_id: ID del numero WhatsApp Business mittente
+ to_phone: numero destinatario (formato E.164, es. +393331234567)
+ text: testo del messaggio
+ access_token: Meta Graph API Bearer token
+ timeout: timeout HTTP in secondi
+
+ Returns:
+ dict con message_id dalla risposta API
+
+ Raises:
+ WhatsAppError: in caso di errore HTTP o risposta API non-ok
+ """
+ url = f"{META_GRAPH_API_URL}/{phone_number_id}/messages"
+ payload = {
+ "messaging_product": "whatsapp",
+ "to": to_phone.replace(" ", "").replace("-", ""),
+ "type": "text",
+ "text": {"body": text},
+ }
+ headers = {
+ "Authorization": f"Bearer {access_token}",
+ "Content-Type": "application/json",
+ }
+
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ try:
+ response = await client.post(url, json=payload, headers=headers)
+ except httpx.TimeoutException as exc:
+ raise WhatsAppError(
+ f"Timeout WhatsApp API dopo {timeout}s"
+ ) from exc
+ except httpx.RequestError as exc:
+ raise WhatsAppError(f"Errore di rete WhatsApp: {exc}") from exc
+
+ if response.status_code == 401:
+ raise WhatsAppError(
+ "Token Meta non valido o scaduto",
+ http_status=401,
+ )
+
+ if response.status_code >= 400:
+ try:
+ err_data = response.json()
+ err_msg = err_data.get("error", {}).get("message", response.text[:200])
+ err_code = err_data.get("error", {}).get("code")
+ except Exception:
+ err_msg = response.text[:200]
+ err_code = None
+ raise WhatsAppError(
+ f"Meta API errore HTTP {response.status_code}: {err_msg}",
+ http_status=response.status_code,
+ api_code=err_code,
+ )
+
+ data = response.json()
+ messages = data.get("messages", [])
+ message_id = messages[0].get("id") if messages else None
+ return {"message_id": message_id, "http_status": response.status_code}
+
+
+async def send_test_whatsapp(
+ phone_number_id: str,
+ to_phone: str,
+ access_token: str,
+ channel_name: str = "PEChub",
+) -> dict:
+ """Invia un messaggio WhatsApp di test per verificare la configurazione."""
+ from datetime import datetime
+
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ text = (
+ f"*PEChub – Test canale WhatsApp*\n\n"
+ f"Il canale _{channel_name}_ e' configurato correttamente.\n\n"
+ f"Data/ora: {ts}"
+ )
+ return await send_whatsapp_message(
+ phone_number_id=phone_number_id,
+ to_phone=to_phone,
+ text=text,
+ access_token=access_token,
+ )
diff --git a/backend/app/schemas/audit_log.py b/backend/app/schemas/audit_log.py
new file mode 100644
index 0000000..875f732
--- /dev/null
+++ b/backend/app/schemas/audit_log.py
@@ -0,0 +1,41 @@
+"""
+Schemi Pydantic per Audit Log.
+"""
+
+import uuid
+from datetime import datetime
+from typing import Any, Optional
+
+from pydantic import BaseModel, field_validator
+
+from app.core.pagination import PaginatedResponse
+
+
+class AuditLogResponse(BaseModel):
+ """Risposta singolo evento audit."""
+
+ id: int
+ tenant_id: Optional[uuid.UUID] = None
+ user_id: Optional[uuid.UUID] = None
+ action: str
+ resource_type: Optional[str] = None
+ resource_id: Optional[uuid.UUID] = None
+ ip_address: Optional[str] = None
+ user_agent: Optional[str] = None
+ payload: Optional[dict] = None
+ outcome: str
+ occurred_at: datetime
+
+ model_config = {"from_attributes": True}
+
+ @field_validator("ip_address", mode="before")
+ @classmethod
+ def coerce_ip_address(cls, v: Any) -> Optional[str]:
+ """Converte IPv4Address/IPv6Address (tipo PostgreSQL INET) in stringa."""
+ if v is None:
+ return None
+ return str(v)
+
+
+# Lista paginata
+AuditLogListResponse = PaginatedResponse[AuditLogResponse]
diff --git a/backend/app/schemas/mailbox.py b/backend/app/schemas/mailbox.py
index 7ada16f..a4d1400 100644
--- a/backend/app/schemas/mailbox.py
+++ b/backend/app/schemas/mailbox.py
@@ -116,3 +116,15 @@ class ConnectionTestResult(BaseModel):
message: str
latency_ms: float | None = None
capabilities: list[str] | None = None # Solo per IMAP
+
+
+class MailboxSyncResponse(BaseModel):
+ """Risposta all'accodamento di un job di sincronizzazione manuale."""
+ status: str
+ mailbox_id: uuid.UUID
+ message: str
+
+
+class MailboxUnreadCountsResponse(BaseModel):
+ """Conteggio messaggi non letti per casella."""
+ counts: dict[str, int]
diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py
index a3a37bc..f3b074a 100644
--- a/backend/app/schemas/message.py
+++ b/backend/app/schemas/message.py
@@ -49,6 +49,12 @@ class MessageResponse(BaseModel):
is_starred: bool = False
is_archived: bool = False
archived_at: Optional[datetime] = None
+ is_trashed: bool = False
+ trashed_at: Optional[datetime] = None
+ is_pending_conservation: bool = False
+ pending_conservation_at: Optional[datetime] = None
+ is_conserved: bool = False
+ conserved_at: Optional[datetime] = None
raw_eml_path: Optional[str] = None
created_at: datetime
updated_at: datetime
@@ -84,12 +90,19 @@ class MessageUpdateRequest(BaseModel):
is_read: Optional[bool] = None
is_starred: Optional[bool] = None
is_archived: Optional[bool] = None
+ is_trashed: Optional[bool] = None
+ is_pending_conservation: Optional[bool] = None
+ is_conserved: Optional[bool] = None
class MessageBulkUpdateRequest(BaseModel):
ids: list[uuid.UUID]
+ is_read: Optional[bool] = None
is_starred: Optional[bool] = None
is_archived: Optional[bool] = None
+ is_trashed: Optional[bool] = None
+ is_pending_conservation: Optional[bool] = None
+ is_conserved: Optional[bool] = None
class MessageBulkUpdateResponse(BaseModel):
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/permission.py b/backend/app/schemas/permission.py
index c09cbfc..8f7b532 100644
--- a/backend/app/schemas/permission.py
+++ b/backend/app/schemas/permission.py
@@ -12,6 +12,7 @@ class PermissionGrantRequest(BaseModel):
can_read: bool = True
can_send: bool = False
can_manage: bool = False
+ can_conserve: bool = False
class PermissionResponse(BaseModel):
@@ -22,6 +23,7 @@ class PermissionResponse(BaseModel):
can_read: bool
can_send: bool
can_manage: bool
+ can_conserve: bool
granted_by: uuid.UUID | None
granted_at: datetime
@@ -36,6 +38,7 @@ class UserMailboxPermissionResponse(BaseModel):
can_read: bool
can_send: bool
can_manage: bool
+ can_conserve: bool
class MailboxUserPermissionResponse(BaseModel):
@@ -47,4 +50,5 @@ class MailboxUserPermissionResponse(BaseModel):
can_read: bool
can_send: bool
can_manage: bool
+ can_conserve: bool
granted_at: datetime
diff --git a/backend/app/schemas/reports.py b/backend/app/schemas/reports.py
new file mode 100644
index 0000000..b14da16
--- /dev/null
+++ b/backend/app/schemas/reports.py
@@ -0,0 +1,75 @@
+"""
+Schemi Pydantic per la Dashboard e Reportistica (Fase 7).
+"""
+
+from datetime import date, datetime
+from typing import Optional
+import uuid
+
+from pydantic import BaseModel, Field
+
+
+class KpiSummary(BaseModel):
+ """Contatori KPI principali del tenant."""
+
+ # Oggi
+ received_today: int = Field(0, description="PEC ricevute oggi")
+ sent_today: int = Field(0, description="PEC inviate oggi (outbound)")
+
+ # Ultimi 7 giorni
+ received_7d: int = Field(0, description="PEC ricevute negli ultimi 7 giorni")
+ sent_7d: int = Field(0, description="PEC inviate negli ultimi 7 giorni")
+
+ # Ultimi 30 giorni
+ received_30d: int = Field(0, description="PEC ricevute negli ultimi 30 giorni")
+ sent_30d: int = Field(0, description="PEC inviate negli ultimi 30 giorni")
+
+ # Stato
+ anomalie_attive: int = Field(0, description="Messaggi outbound in stato anomaly")
+ tasso_consegna: float = Field(0.0, description="Percentuale consegna (0-100)")
+ caselle_in_errore: int = Field(0, description="Caselle con status=error")
+ messaggi_non_letti: int = Field(0, description="Messaggi inbound non letti")
+
+ # Totali assoluti
+ totale_messaggi: int = Field(0, description="Totale messaggi nel tenant")
+
+
+class DailyStat(BaseModel):
+ """Statistiche giornaliere per il grafico a barre."""
+
+ day: date = Field(..., description="Data (YYYY-MM-DD)")
+ received: int = Field(0, description="PEC ricevute in quel giorno")
+ sent: int = Field(0, description="PEC inviate in quel giorno")
+
+
+class OutboundStateStat(BaseModel):
+ """Conteggio messaggi outbound per stato (per il grafico a torta)."""
+
+ state: str
+ count: int
+
+
+class MailboxStat(BaseModel):
+ """Statistiche per singola casella."""
+
+ mailbox_id: uuid.UUID
+ email_address: str
+ display_name: Optional[str] = None
+ status: str
+ received_total: int = 0
+ sent_total: int = 0
+ anomalie: int = 0
+ non_letti: int = 0
+ last_sync_at: Optional[datetime] = None
+
+
+class ReportSummaryResponse(BaseModel):
+ """Risposta completa dell'endpoint /reports/summary."""
+
+ generated_at: datetime
+ period_days: int = Field(..., description="Numero di giorni del periodo selezionato")
+
+ kpi: KpiSummary
+ daily_stats: list[DailyStat] = Field(default_factory=list)
+ outbound_states: list[OutboundStateStat] = Field(default_factory=list)
+ mailbox_stats: list[MailboxStat] = Field(default_factory=list)
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/schemas/tenant_settings.py b/backend/app/schemas/tenant_settings.py
index d200349..0556c9f 100644
--- a/backend/app/schemas/tenant_settings.py
+++ b/backend/app/schemas/tenant_settings.py
@@ -1,12 +1,13 @@
"""
Schema Pydantic per TenantSettings – lettura e aggiornamento impostazioni tenant.
+Include schemi per il modulo di indicizzazione full-text.
"""
import uuid
from datetime import datetime
-from typing import Literal
+from typing import Literal, Optional
-from pydantic import BaseModel, Field, HttpUrl, field_validator
+from pydantic import BaseModel, Field, field_validator
ArchivalMode = Literal["mock", "production"]
@@ -68,3 +69,53 @@ class TenantSettingsUpdate(BaseModel):
if v is not None and v not in ("mock", "production"):
raise ValueError("archival_mode deve essere 'mock' o 'production'")
return v
+
+
+# ─── Schemi indicizzazione full-text ──────────────────────────────────────────
+
+class IndexingStats(BaseModel):
+ """Statistiche di copertura dell'indicizzazione per un tenant."""
+
+ total_messages: int
+ indexed_messages: int
+ unindexed_messages: int
+ coverage_pct: float # percentuale messaggi con search_vector != NULL
+
+ attachments_total: int # allegati PDF/DOCX totali
+ attachments_extracted: int # allegati con testo estratto
+ attachments_pct: float # percentuale allegati con testo estratto
+
+
+class IndexingJobStatus(BaseModel):
+ """Stato di un job di reindex in corso o completato."""
+
+ status: str # idle | running | completed | failed | cancelled
+ mode: Optional[str] = None # full | differential
+ total: int = 0
+ processed: int = 0
+ progress_pct: float = 0.0
+
+ started_at: Optional[str] = None # ISO datetime
+ finished_at: Optional[str] = None # ISO datetime
+ started_by: Optional[str] = None # email utente che ha avviato il job
+ elapsed_seconds: Optional[int] = None
+ is_stale: bool = False # True se running da piu' di STALE_THRESHOLD_HOURS
+ error: Optional[str] = None
+
+
+class StartReindexRequest(BaseModel):
+ """Body per POST /settings/indexing/reindex."""
+
+ mode: Literal["full", "differential"] = "differential"
+
+
+class StartRescanRequest(BaseModel):
+ """Body per POST /settings/indexing/rescan."""
+
+ force: bool = Field(
+ default=False,
+ description=(
+ "False (default): estrae solo allegati con extracted_text NULL. "
+ "True: ri-estrae tutti gli allegati, sovrascrivendo i testi gia' presenti."
+ ),
+ )
diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py
new file mode 100644
index 0000000..7cf7bde
--- /dev/null
+++ b/backend/app/services/audit_service.py
@@ -0,0 +1,153 @@
+"""
+Servizio Audit Log – registrazione e consultazione degli eventi di sistema.
+
+Uso tipico nei router/servizi:
+ from app.services.audit_service import log_audit
+
+ await log_audit(
+ db=db,
+ tenant_id=current_user.tenant_id,
+ user_id=current_user.id,
+ action="user.created",
+ resource_type="user",
+ resource_id=new_user.id,
+ outcome="success",
+ ip_address=request.client.host if request.client else None,
+ user_agent=request.headers.get("user-agent"),
+ payload={"email": new_user.email},
+ )
+"""
+
+import math
+import uuid
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import select, func, and_
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.pagination import PaginatedResponse, PaginationParams
+from app.models.audit_log import AuditLog
+from app.schemas.audit_log import AuditLogResponse
+
+
+# ─── Helper standalone (da chiamare ovunque senza istanziare la classe) ───────
+
+async def log_audit(
+ db: AsyncSession,
+ action: str,
+ *,
+ tenant_id: Optional[uuid.UUID] = None,
+ user_id: Optional[uuid.UUID] = None,
+ resource_type: Optional[str] = None,
+ resource_id: Optional[uuid.UUID] = None,
+ outcome: str = "success",
+ ip_address: Optional[str] = None,
+ user_agent: Optional[str] = None,
+ payload: Optional[dict] = None,
+) -> None:
+ """
+ Inserisce un record di audit log nella sessione corrente.
+ Non fa commit: il commit avviene con la transazione del chiamante.
+ Non solleva eccezioni: gli errori sono loggati ma non propagati
+ per evitare di bloccare l'operazione principale.
+ """
+ try:
+ entry = AuditLog(
+ tenant_id=tenant_id,
+ user_id=user_id,
+ action=action,
+ resource_type=resource_type,
+ resource_id=resource_id,
+ ip_address=ip_address,
+ user_agent=user_agent,
+ payload=payload or {},
+ outcome=outcome,
+ )
+ db.add(entry)
+ except Exception:
+ # Mai bloccare l'operazione principale per un errore di audit
+ import logging
+ logging.getLogger(__name__).warning(
+ "Impossibile registrare evento audit: action=%s", action, exc_info=True
+ )
+
+
+# ─── Servizio per query (usato dal router) ────────────────────────────────────
+
+class AuditService:
+ def __init__(self, db: AsyncSession) -> None:
+ self.db = db
+
+ async def list(
+ self,
+ *,
+ tenant_id: Optional[uuid.UUID],
+ page: int = 1,
+ page_size: int = 25,
+ action: Optional[str] = None,
+ user_id: Optional[uuid.UUID] = None,
+ outcome: Optional[str] = None,
+ date_from: Optional[datetime] = None,
+ date_to: Optional[datetime] = None,
+ resource_type: Optional[str] = None,
+ ) -> PaginatedResponse[AuditLogResponse]:
+ """
+ Restituisce la lista paginata degli eventi audit.
+
+ Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant.
+ """
+ filters = []
+
+ if tenant_id is not None:
+ filters.append(AuditLog.tenant_id == tenant_id)
+
+ if action:
+ # Supporta prefisso: "auth." corrisponde a tutti gli eventi auth.*
+ if action.endswith("*"):
+ filters.append(AuditLog.action.like(action[:-1] + "%"))
+ else:
+ filters.append(AuditLog.action == action)
+
+ if user_id:
+ filters.append(AuditLog.user_id == user_id)
+
+ if outcome:
+ filters.append(AuditLog.outcome == outcome)
+
+ if date_from:
+ filters.append(AuditLog.occurred_at >= date_from)
+
+ if date_to:
+ filters.append(AuditLog.occurred_at <= date_to)
+
+ if resource_type:
+ filters.append(AuditLog.resource_type == resource_type)
+
+ where_clause = and_(*filters) if filters else True # type: ignore[arg-type]
+
+ # Count totale
+ count_q = select(func.count()).select_from(AuditLog).where(where_clause)
+ total = (await self.db.execute(count_q)).scalar_one()
+
+ # Dati paginati
+ offset = (page - 1) * page_size
+ items_q = (
+ select(AuditLog)
+ .where(where_clause)
+ .order_by(AuditLog.occurred_at.desc())
+ .offset(offset)
+ .limit(page_size)
+ )
+ result = await self.db.execute(items_q)
+ items = list(result.scalars().all())
+
+ pages = math.ceil(total / page_size) if page_size > 0 else 0
+
+ return PaginatedResponse[AuditLogResponse](
+ items=[AuditLogResponse.model_validate(item) for item in items],
+ total=total,
+ page=page,
+ page_size=page_size,
+ pages=pages,
+ )
diff --git a/backend/app/services/indexing_service.py b/backend/app/services/indexing_service.py
new file mode 100644
index 0000000..28424ff
--- /dev/null
+++ b/backend/app/services/indexing_service.py
@@ -0,0 +1,1226 @@
+"""
+Servizio di gestione indicizzazione full-text dei messaggi.
+
+Funzionalita':
+ - Statistiche sull'indicizzazione (messaggi indicizzati vs totali)
+ - Avvio reindex totale o differenziale in background
+ - Monitoraggio progresso tramite Redis
+ - Cancellazione di un job in corso
+ - Avvio rescan allegati: ri-estrazione testo da MinIO + aggiornamento search_vector
+
+Stato del job reindex salvato in Redis:
+ pechub:reindex:{tenant_id} -> JSON con stato corrente (TTL 24h)
+ pechub:reindex:{tenant_id}:cancel -> flag cancellazione (TTL 10min)
+
+Stato del job rescan salvato in Redis:
+ pechub:rescan:{tenant_id} -> JSON con stato corrente (TTL 24h)
+ pechub:rescan:{tenant_id}:cancel -> flag cancellazione (TTL 10min)
+
+Il background task usa una sessione DB propria (non quella della request).
+"""
+
+import asyncio
+import io
+import json
+import logging
+import re
+import uuid
+from datetime import datetime, timezone
+from typing import Literal, Optional
+
+from sqlalchemy import func, select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.logging import get_logger
+
+logger = get_logger(__name__)
+
+# ─── Costanti ─────────────────────────────────────────────────────────────────
+
+REDIS_KEY_PREFIX = "pechub:reindex"
+REDIS_RESCAN_PREFIX = "pechub:rescan"
+REDIS_TTL_STATUS = 60 * 60 * 24 # 24 ore
+REDIS_TTL_CANCEL = 60 * 10 # 10 minuti
+BATCH_SIZE = 500 # messaggi per batch (reindex)
+RESCAN_BATCH_SIZE = 50 # allegati per batch (rescan - piu' pesante)
+STALE_THRESHOLD_HOURS = 2 # ore prima di segnalare un job come stale
+
+MAX_EXTRACTED_TEXT_LEN = 50_000
+MAX_COMBINED_TEXT_LEN = 200_000
+
+ReindexMode = Literal["full", "differential"]
+JobStatus = Literal["idle", "running", "completed", "failed", "cancelled"]
+
+# ─── Content-type e estensioni supportate per rescan ─────────────────────────
+
+_SUPPORTED_EXTENSIONS = {
+ "pdf", "docx", "doc", "xlsx", "xls", "pptx", "ppt",
+ "odt", "ods", "odp", "rtf", "txt", "csv", "xml",
+ "html", "htm", "json", "eml", "msg", "p7m",
+ "md",
+ # Immagini (OCR)
+ "png", "jpg", "jpeg", "tiff", "tif", "bmp", "gif", "webp",
+}
+
+_SUPPORTED_CONTENT_TYPES = {
+ "application/pdf",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/msword",
+ "application/vnd.ms-word",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.ms-excel",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/vnd.ms-powerpoint",
+ "application/vnd.oasis.opendocument.text",
+ "application/vnd.oasis.opendocument.spreadsheet",
+ "application/vnd.oasis.opendocument.presentation",
+ "application/rtf",
+ "text/rtf",
+ "text/plain",
+ "text/csv",
+ "text/xml",
+ "application/xml",
+ "text/html",
+ "message/rfc822",
+ "application/pkcs7-mime",
+ "application/x-pkcs7-mime",
+ "text/markdown",
+ # Immagini (OCR)
+ "image/png",
+ "image/jpeg",
+ "image/tiff",
+ "image/bmp",
+ "image/gif",
+ "image/webp",
+}
+
+
+# ─── Helpers Redis ─────────────────────────────────────────────────────────────
+
+def _redis_key(tenant_id: uuid.UUID) -> str:
+ return f"{REDIS_KEY_PREFIX}:{tenant_id}"
+
+
+def _redis_cancel_key(tenant_id: uuid.UUID) -> str:
+ return f"{REDIS_KEY_PREFIX}:{tenant_id}:cancel"
+
+
+def _redis_rescan_key(tenant_id: uuid.UUID) -> str:
+ return f"{REDIS_RESCAN_PREFIX}:{tenant_id}"
+
+
+def _redis_rescan_cancel_key(tenant_id: uuid.UUID) -> str:
+ return f"{REDIS_RESCAN_PREFIX}:{tenant_id}:cancel"
+
+
+# ─── Estrattori testo allegati ─────────────────────────────────────────────────
+
+def _ext(filename: str | None) -> str:
+ """Restituisce l'estensione del file in minuscolo, senza punto."""
+ if not filename:
+ return ""
+ fn = filename.lower()
+ if fn.endswith(".p7m"):
+ return "p7m"
+ idx = fn.rfind(".")
+ return fn[idx + 1:] if idx >= 0 else ""
+
+
+# Soglia minima di caratteri estratti da pypdf prima di ricorrere all'OCR.
+_PDF_OCR_THRESHOLD = 50
+# Numero massimo di pagine OCR per evitare timeout su PDF lunghi.
+_PDF_OCR_MAX_PAGES = 15
+
+
+def _extract_pdf(content: bytes) -> str:
+ """
+ Estrae testo da PDF tramite pypdf.
+
+ Se il testo estratto e' inferiore a _PDF_OCR_THRESHOLD caratteri (PDF
+ image-only / scansione), attiva il fallback OCR via Tesseract.
+ """
+ try:
+ import pypdf # type: ignore[import]
+ reader = pypdf.PdfReader(io.BytesIO(content))
+ parts: list[str] = []
+ for page in reader.pages:
+ try:
+ t = page.extract_text()
+ if t:
+ parts.append(t)
+ except Exception:
+ continue
+ text = " ".join(parts)
+ except ImportError:
+ logger.warning("pypdf non installato: impossibile estrarre testo da PDF")
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore estrazione PDF: {e}")
+ return ""
+
+ if len(text.strip()) < _PDF_OCR_THRESHOLD:
+ logger.debug(
+ f"PDF con testo insufficiente ({len(text.strip())} char), "
+ "tentativo OCR..."
+ )
+ ocr_text = _extract_pdf_ocr(content)
+ if len(ocr_text.strip()) > len(text.strip()):
+ return ocr_text
+
+ return text
+
+
+def _extract_pdf_ocr(content: bytes) -> str:
+ """
+ OCR su PDF image-only tramite pdf2image + Tesseract.
+
+ Converte le pagine a 200 DPI e applica Tesseract con lingua italiana + inglese.
+ Processa al massimo _PDF_OCR_MAX_PAGES pagine per evitare timeout.
+ """
+ try:
+ from pdf2image import convert_from_bytes # type: ignore[import]
+ import pytesseract # type: ignore[import]
+
+ pages = convert_from_bytes(
+ content,
+ dpi=200,
+ last_page=_PDF_OCR_MAX_PAGES,
+ )
+ parts: list[str] = []
+ for page_img in pages:
+ try:
+ t = pytesseract.image_to_string(page_img, lang="ita+eng")
+ if t and t.strip():
+ parts.append(t.strip())
+ except Exception:
+ continue
+ return " ".join(parts)
+ except ImportError:
+ logger.warning(
+ "pdf2image o pytesseract non installati: impossibile OCR PDF"
+ )
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore OCR PDF: {e}")
+ return ""
+
+
+def _extract_image_ocr(content: bytes) -> str:
+ """
+ Estrae testo da un file immagine (PNG, JPEG, TIFF, BMP, ecc.) tramite OCR.
+
+ Usa Tesseract con lingua italiana + inglese per massima copertura
+ su documenti italiani.
+ """
+ try:
+ import pytesseract # type: ignore[import]
+ from PIL import Image # type: ignore[import]
+
+ img = Image.open(io.BytesIO(content))
+ if img.mode not in ("RGB", "L"):
+ img = img.convert("RGB")
+ text = pytesseract.image_to_string(img, lang="ita+eng")
+ return " ".join(text.split())
+ except ImportError:
+ logger.warning(
+ "pytesseract o Pillow non installati: impossibile OCR immagine"
+ )
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore OCR immagine: {e}")
+ return ""
+
+
+def _extract_docx(content: bytes) -> str:
+ try:
+ import docx # type: ignore[import]
+ doc = docx.Document(io.BytesIO(content))
+ parts = [p.text for p in doc.paragraphs if p.text and p.text.strip()]
+ for table in doc.tables:
+ for row in table.rows:
+ for cell in row.cells:
+ if cell.text and cell.text.strip():
+ parts.append(cell.text.strip())
+ return " ".join(parts)
+ except ImportError:
+ logger.warning("python-docx non installato: impossibile estrarre testo da DOCX")
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore estrazione DOCX: {e}")
+ return ""
+
+
+def _extract_doc(content: bytes) -> str:
+ """
+ Estrae testo da file DOC (formato OLE2/legacy Microsoft Word).
+
+ Prima tenta python-docx (gestisce .docx eventualmente rinominati come .doc).
+ Se fallisce, esegue uno scan del binario OLE2 estraendo sequenze di caratteri
+ stampabili di almeno 5 caratteri consecutivi (approccio 'strings').
+ Non richiede librerie aggiuntive e funziona per la maggior parte dei .doc
+ in lingua italiana/europea (ASCII + Latin-1).
+ """
+ # Tentativo 1: python-docx (per .docx rinominati o ZIP-based)
+ result = _extract_docx(content)
+ if result.strip():
+ return result
+
+ # Tentativo 2: scan binario ASCII per OLE2
+ try:
+ run: list[int] = []
+ parts: list[str] = []
+ for byte in content:
+ if 32 <= byte <= 126: # ASCII stampabile
+ run.append(byte)
+ else:
+ if len(run) >= 5:
+ parts.append(bytes(run).decode("ascii", errors="ignore"))
+ run = []
+ if len(run) >= 5:
+ parts.append(bytes(run).decode("ascii", errors="ignore"))
+
+ # Mantieni solo sequenze con almeno 3 lettere (filtra sequenze di simboli)
+ meaningful = [p for p in parts if sum(1 for c in p if c.isalpha()) >= 3]
+ if meaningful:
+ text = " ".join(meaningful)
+ return " ".join(text.split())[:MAX_EXTRACTED_TEXT_LEN]
+ except Exception as e:
+ logger.debug(f"Errore estrazione DOC OLE2 binario: {e}")
+
+ return ""
+
+
+def _extract_plain(content: bytes) -> str:
+ try:
+ try:
+ txt = content.decode("utf-8")
+ except UnicodeDecodeError:
+ txt = content.decode("latin-1", errors="replace")
+ if "<" in txt and ">" in txt:
+ txt = re.sub(r"<[^>]+>", " ", txt)
+ txt = re.sub(r"&[a-zA-Z]+;", " ", txt)
+ return " ".join(txt.split())
+ except Exception as e:
+ logger.debug(f"Errore estrazione plain: {e}")
+ return ""
+
+
+def _extract_eml(content: bytes) -> str:
+ try:
+ import email as emaillib
+ msg = emaillib.message_from_bytes(content)
+ parts: list[str] = []
+ subject = msg.get("Subject", "")
+ if subject:
+ parts.append(subject)
+ sender = msg.get("From", "")
+ if sender:
+ parts.append(sender)
+ if msg.is_multipart():
+ for part in msg.walk():
+ ct = part.get_content_type()
+ if ct == "text/plain":
+ try:
+ payload = part.get_payload(decode=True)
+ if payload:
+ charset = part.get_content_charset() or "utf-8"
+ parts.append(payload.decode(charset, errors="replace"))
+ except Exception:
+ pass
+ else:
+ payload = msg.get_payload(decode=True)
+ if payload:
+ charset = msg.get_content_charset() or "utf-8"
+ parts.append(payload.decode(charset, errors="replace")) # type: ignore[arg-type]
+ return " ".join(parts)
+ except Exception as e:
+ logger.debug(f"Errore estrazione EML: {e}")
+ return ""
+
+
+def _unwrap_p7m_asn1(data: bytes) -> bytes | None:
+ def read_tag_length(buf: bytes, offset: int):
+ tag = buf[offset]
+ offset += 1
+ lb = buf[offset]
+ offset += 1
+ if lb & 0x80:
+ num_bytes = lb & 0x7F
+ ln = int.from_bytes(buf[offset:offset + num_bytes], "big")
+ offset += num_bytes
+ else:
+ ln = lb
+ return tag, ln, offset
+
+ pos = 0
+ try:
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x30:
+ return None
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x06:
+ return None
+ pos += ln
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0xA0:
+ return None
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x30:
+ return None
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x02:
+ return None
+ pos += ln
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x31:
+ return None
+ pos += ln
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x30:
+ return None
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x06:
+ return None
+ pos += ln
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0xA0:
+ return None
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x04:
+ return None
+ return data[pos: pos + ln]
+ except Exception:
+ return None
+
+
+def _extract_p7m(content: bytes, original_filename: str | None = None) -> str:
+ inner_content = _unwrap_p7m_asn1(content)
+ if not inner_content:
+ return ""
+ inner_ext = ""
+ if original_filename:
+ fn = original_filename.lower()
+ if fn.endswith(".p7m"):
+ fn = fn[:-4]
+ idx = fn.rfind(".")
+ if idx >= 0:
+ inner_ext = fn[idx + 1:]
+ extractor = _EXTRACTORS_SYNC.get(inner_ext)
+ if extractor:
+ return extractor(inner_content)
+ if inner_content[:4] == b"%PDF":
+ return _extract_pdf(inner_content)
+ if inner_content[:2] == b"PK":
+ for fn_try in (_extract_docx, _extract_plain):
+ result = fn_try(inner_content)
+ if result.strip():
+ return result
+ return _extract_plain(inner_content)
+
+
+_EXTRACTORS_SYNC: dict = {
+ "pdf": _extract_pdf,
+ "docx": _extract_docx,
+ "doc": _extract_doc, # usa fallback OLE2 per .doc legacy
+ "txt": _extract_plain,
+ "csv": _extract_plain,
+ "xml": _extract_plain,
+ "html": _extract_plain,
+ "htm": _extract_plain,
+ "json": _extract_plain,
+ "md": _extract_plain, # Markdown e' testo semplice
+ "eml": _extract_eml,
+ "msg": _extract_eml,
+ "p7m": _extract_p7m,
+ # Immagini (OCR)
+ "png": _extract_image_ocr,
+ "jpg": _extract_image_ocr,
+ "jpeg": _extract_image_ocr,
+ "tiff": _extract_image_ocr,
+ "tif": _extract_image_ocr,
+ "bmp": _extract_image_ocr,
+ "gif": _extract_image_ocr,
+ "webp": _extract_image_ocr,
+}
+
+
+def _resolve_extractor(content_type: str | None, filename: str | None):
+ """Ritorna la funzione estrattore appropriata, o None."""
+ e = _ext(filename)
+ if e in _EXTRACTORS_SYNC:
+ return _EXTRACTORS_SYNC[e]
+ ct = (content_type or "").lower()
+ _ct_map = {
+ "application/pdf": "pdf",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
+ "application/msword": "doc",
+ "application/vnd.ms-word": "doc",
+ "text/plain": "txt",
+ "text/csv": "csv",
+ "text/xml": "xml",
+ "application/xml": "xml",
+ "text/html": "html",
+ "text/markdown": "md",
+ "message/rfc822": "eml",
+ "application/pkcs7-mime": "p7m",
+ "application/x-pkcs7-mime": "p7m",
+ # Immagini (OCR)
+ "image/png": "png",
+ "image/jpeg": "jpeg",
+ "image/tiff": "tiff",
+ "image/bmp": "bmp",
+ "image/gif": "gif",
+ "image/webp": "webp",
+ }
+ mapped = _ct_map.get(ct)
+ if mapped:
+ return _EXTRACTORS_SYNC.get(mapped)
+ return None
+
+
+def _is_extractable(content_type: str | None, filename: str | None) -> bool:
+ e = _ext(filename)
+ if e in _SUPPORTED_EXTENSIONS:
+ return True
+ ct = (content_type or "").lower()
+ return ct in _SUPPORTED_CONTENT_TYPES
+
+
+# ─── Servizio ──────────────────────────────────────────────────────────────────
+
+class IndexingService:
+ """Gestisce le operazioni di indicizzazione full-text per un tenant."""
+
+ def __init__(self, db: AsyncSession) -> None:
+ self.db = db
+
+ # ── Statistiche ──────────────────────────────────────────────────────────
+
+ async def get_stats(self, tenant_id: uuid.UUID) -> dict:
+ """
+ Restituisce le statistiche di copertura dell'indicizzazione per il tenant.
+ """
+ from app.models.message import Attachment, Message
+
+ total_q = await self.db.execute(
+ select(func.count(Message.id)).where(
+ Message.tenant_id == tenant_id,
+ Message.parent_message_id.is_(None),
+ )
+ )
+ total_messages: int = total_q.scalar_one()
+
+ indexed_q = await self.db.execute(
+ select(func.count(Message.id)).where(
+ Message.tenant_id == tenant_id,
+ Message.parent_message_id.is_(None),
+ Message.search_vector.isnot(None),
+ )
+ )
+ indexed_messages: int = indexed_q.scalar_one()
+
+ _supported_content_types_list = list(_SUPPORTED_CONTENT_TYPES)
+ _supported_extensions_like = [f"%.{e}" for e in _SUPPORTED_EXTENSIONS]
+
+ from sqlalchemy import or_
+ ext_conditions = [Attachment.filename.ilike(ext) for ext in _supported_extensions_like]
+
+ att_total_q = await self.db.execute(
+ select(func.count(Attachment.id)).where(
+ Attachment.tenant_id == tenant_id,
+ or_(
+ Attachment.content_type.in_(_supported_content_types_list),
+ *ext_conditions,
+ ),
+ )
+ )
+ attachments_total: int = att_total_q.scalar_one()
+
+ att_extracted_q = await self.db.execute(
+ select(func.count(Attachment.id)).where(
+ Attachment.tenant_id == tenant_id,
+ Attachment.extracted_text.isnot(None),
+ )
+ )
+ attachments_extracted: int = att_extracted_q.scalar_one()
+
+ unindexed_messages = total_messages - indexed_messages
+ coverage_pct = (
+ round(indexed_messages / total_messages * 100, 1)
+ if total_messages > 0
+ else 100.0
+ )
+ attachments_pct = (
+ round(attachments_extracted / attachments_total * 100, 1)
+ if attachments_total > 0
+ else 100.0
+ )
+
+ return {
+ "total_messages": total_messages,
+ "indexed_messages": indexed_messages,
+ "unindexed_messages": unindexed_messages,
+ "coverage_pct": coverage_pct,
+ "attachments_total": attachments_total,
+ "attachments_extracted": attachments_extracted,
+ "attachments_pct": attachments_pct,
+ }
+
+ # ── Stato job reindex ─────────────────────────────────────────────────────
+
+ @staticmethod
+ async def get_job_status(tenant_id: uuid.UUID, redis_url: str) -> dict:
+ """Legge lo stato del job di reindex da Redis."""
+ return await IndexingService._read_job_state(
+ _redis_key(tenant_id), redis_url
+ )
+
+ # ── Stato job rescan ──────────────────────────────────────────────────────
+
+ @staticmethod
+ async def get_rescan_status(tenant_id: uuid.UUID, redis_url: str) -> dict:
+ """Legge lo stato del job di rescan allegati da Redis."""
+ return await IndexingService._read_job_state(
+ _redis_rescan_key(tenant_id), redis_url
+ )
+
+ @staticmethod
+ async def _read_job_state(redis_key_str: str, redis_url: str) -> dict:
+ """Helper generico: legge uno stato job da Redis."""
+ import redis.asyncio as aioredis
+
+ client = aioredis.from_url(redis_url, decode_responses=True)
+ try:
+ raw = await client.get(redis_key_str)
+ finally:
+ await client.aclose()
+
+ if not raw:
+ return {
+ "status": "idle",
+ "mode": None,
+ "total": 0,
+ "processed": 0,
+ "progress_pct": 0.0,
+ "started_at": None,
+ "finished_at": None,
+ "started_by": None,
+ "elapsed_seconds": None,
+ "is_stale": False,
+ "error": None,
+ }
+
+ try:
+ data: dict = json.loads(raw)
+ except json.JSONDecodeError:
+ return {"status": "idle"}
+
+ is_stale = False
+ elapsed_seconds = None
+ if data.get("started_at"):
+ try:
+ started = datetime.fromisoformat(data["started_at"])
+ finished_str = data.get("finished_at")
+ ref_time = (
+ datetime.fromisoformat(finished_str)
+ if finished_str
+ else datetime.now(timezone.utc)
+ )
+ elapsed_seconds = int((ref_time - started).total_seconds())
+ if data.get("status") == "running":
+ elapsed_hours = elapsed_seconds / 3600
+ is_stale = elapsed_hours >= STALE_THRESHOLD_HOURS
+ except Exception:
+ pass
+
+ total = data.get("total", 0)
+ processed = data.get("processed", 0)
+ progress_pct = round(processed / total * 100, 1) if total > 0 else 0.0
+
+ return {
+ "status": data.get("status", "idle"),
+ "mode": data.get("mode"),
+ "total": total,
+ "processed": processed,
+ "progress_pct": progress_pct,
+ "started_at": data.get("started_at"),
+ "finished_at": data.get("finished_at"),
+ "started_by": data.get("started_by"),
+ "elapsed_seconds": elapsed_seconds,
+ "is_stale": is_stale,
+ "error": data.get("error"),
+ }
+
+ # ── Avvio reindex ─────────────────────────────────────────────────────────
+
+ @staticmethod
+ async def start_reindex(
+ tenant_id: uuid.UUID,
+ mode: ReindexMode,
+ started_by_email: str,
+ redis_url: str,
+ db_url: str,
+ ) -> None:
+ import redis.asyncio as aioredis
+
+ client = aioredis.from_url(redis_url, decode_responses=True)
+ try:
+ raw = await client.get(_redis_key(tenant_id))
+ if raw:
+ data = json.loads(raw)
+ if data.get("status") == "running":
+ raise ValueError("Un job di reindex e' gia' in corso per questo tenant")
+ # Controlla anche se il rescan e' in corso
+ raw_rescan = await client.get(_redis_rescan_key(tenant_id))
+ if raw_rescan:
+ data_rescan = json.loads(raw_rescan)
+ if data_rescan.get("status") == "running":
+ raise ValueError(
+ "Un job di scansione allegati e' in corso. "
+ "Attendi il termine prima di avviare il reindex."
+ )
+ finally:
+ await client.aclose()
+
+ await IndexingService._set_state(
+ _redis_key(tenant_id),
+ redis_url,
+ {
+ "status": "running",
+ "mode": mode,
+ "total": 0,
+ "processed": 0,
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "finished_at": None,
+ "started_by": started_by_email,
+ "error": None,
+ },
+ )
+
+ asyncio.create_task(
+ IndexingService._run_reindex_bg(tenant_id, mode, redis_url, db_url),
+ name=f"reindex-{tenant_id}",
+ )
+
+ # ── Avvio rescan allegati ─────────────────────────────────────────────────
+
+ @staticmethod
+ async def start_rescan(
+ tenant_id: uuid.UUID,
+ started_by_email: str,
+ redis_url: str,
+ db_url: str,
+ force: bool = False,
+ ) -> None:
+ """
+ Avvia il job di rescan allegati in background.
+
+ force=False: processa solo allegati con extracted_text IS NULL
+ force=True: processa tutti gli allegati (ri-estrae anche quelli gia' estratti)
+ """
+ import redis.asyncio as aioredis
+
+ client = aioredis.from_url(redis_url, decode_responses=True)
+ try:
+ raw = await client.get(_redis_rescan_key(tenant_id))
+ if raw:
+ data = json.loads(raw)
+ if data.get("status") == "running":
+ raise ValueError("Un job di scansione allegati e' gia' in corso per questo tenant")
+ # Controlla anche se il reindex e' in corso
+ raw_reindex = await client.get(_redis_key(tenant_id))
+ if raw_reindex:
+ data_reindex = json.loads(raw_reindex)
+ if data_reindex.get("status") == "running":
+ raise ValueError(
+ "Un job di reindex e' in corso. "
+ "Attendi il termine prima di avviare la scansione allegati."
+ )
+ finally:
+ await client.aclose()
+
+ mode_label = "force" if force else "differential"
+
+ await IndexingService._set_state(
+ _redis_rescan_key(tenant_id),
+ redis_url,
+ {
+ "status": "running",
+ "mode": mode_label,
+ "total": 0,
+ "processed": 0,
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "finished_at": None,
+ "started_by": started_by_email,
+ "error": None,
+ },
+ )
+
+ asyncio.create_task(
+ IndexingService._run_rescan_bg(tenant_id, force, redis_url, db_url),
+ name=f"rescan-{tenant_id}",
+ )
+
+ # ── Cancellazione reindex ─────────────────────────────────────────────────
+
+ @staticmethod
+ async def cancel_reindex(tenant_id: uuid.UUID, redis_url: str) -> bool:
+ import redis.asyncio as aioredis
+
+ client = aioredis.from_url(redis_url, decode_responses=True)
+ try:
+ raw = await client.get(_redis_key(tenant_id))
+ if raw:
+ data = json.loads(raw)
+ if data.get("status") == "running":
+ await client.setex(
+ _redis_cancel_key(tenant_id),
+ REDIS_TTL_CANCEL,
+ "1",
+ )
+ return True
+ finally:
+ await client.aclose()
+ return False
+
+ # ── Cancellazione rescan ──────────────────────────────────────────────────
+
+ @staticmethod
+ async def cancel_rescan(tenant_id: uuid.UUID, redis_url: str) -> bool:
+ import redis.asyncio as aioredis
+
+ client = aioredis.from_url(redis_url, decode_responses=True)
+ try:
+ raw = await client.get(_redis_rescan_key(tenant_id))
+ if raw:
+ data = json.loads(raw)
+ if data.get("status") == "running":
+ await client.setex(
+ _redis_rescan_cancel_key(tenant_id),
+ REDIS_TTL_CANCEL,
+ "1",
+ )
+ return True
+ finally:
+ await client.aclose()
+ return False
+
+ # ── Helpers Redis ─────────────────────────────────────────────────────────
+
+ @staticmethod
+ async def _set_state(redis_key_str: str, redis_url: str, state: dict) -> None:
+ import redis.asyncio as aioredis
+
+ client = aioredis.from_url(redis_url, decode_responses=True)
+ try:
+ await client.setex(
+ redis_key_str,
+ REDIS_TTL_STATUS,
+ json.dumps(state, default=str),
+ )
+ finally:
+ await client.aclose()
+
+ @staticmethod
+ async def _check_cancel_flag(cancel_key: str, redis_url: str) -> bool:
+ import redis.asyncio as aioredis
+
+ client = aioredis.from_url(redis_url, decode_responses=True)
+ try:
+ flag = await client.get(cancel_key)
+ return flag == "1"
+ finally:
+ await client.aclose()
+
+ # Alias per retrocompatibilita' con il codice esistente
+ @staticmethod
+ async def _set_job_state(tenant_id: uuid.UUID, redis_url: str, state: dict) -> None:
+ await IndexingService._set_state(_redis_key(tenant_id), redis_url, state)
+
+ @staticmethod
+ async def _check_cancel(tenant_id: uuid.UUID, redis_url: str) -> bool:
+ return await IndexingService._check_cancel_flag(
+ _redis_cancel_key(tenant_id), redis_url
+ )
+
+ # ── Logica interna reindex ─────────────────────────────────────────────────
+
+ @staticmethod
+ async def _run_reindex_bg(
+ tenant_id: uuid.UUID,
+ mode: ReindexMode,
+ redis_url: str,
+ db_url: str,
+ ) -> None:
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+ from sqlalchemy.orm import sessionmaker
+
+ log = logging.getLogger(__name__)
+ log.info(f"Avvio reindex {mode} per tenant {tenant_id}")
+
+ engine = create_async_engine(db_url, echo=False)
+ AsyncSessionFactory = sessionmaker( # type: ignore[call-overload]
+ engine, class_=AsyncSession, expire_on_commit=False
+ )
+
+ state: dict = {
+ "status": "running",
+ "mode": mode,
+ "total": 0,
+ "processed": 0,
+ "started_at": None,
+ "finished_at": None,
+ "started_by": None,
+ "error": None,
+ }
+
+ try:
+ async with AsyncSessionFactory() as db:
+ from app.models.message import Message
+
+ count_q = select(func.count(Message.id)).where(
+ Message.tenant_id == tenant_id,
+ Message.parent_message_id.is_(None),
+ )
+ if mode == "differential":
+ count_q = count_q.where(Message.search_vector.is_(None))
+
+ total: int = (await db.execute(count_q)).scalar_one()
+
+ import redis.asyncio as aioredis
+ redis_client = aioredis.from_url(redis_url, decode_responses=True)
+ try:
+ raw = await redis_client.get(_redis_key(tenant_id))
+ if raw:
+ state = json.loads(raw)
+ state["total"] = total
+ state["processed"] = 0
+ await redis_client.setex(
+ _redis_key(tenant_id),
+ REDIS_TTL_STATUS,
+ json.dumps(state, default=str),
+ )
+ finally:
+ await redis_client.aclose()
+
+ log.info(f"Reindex {mode}: {total} messaggi da processare")
+
+ if total == 0:
+ state.update({
+ "status": "completed",
+ "processed": 0,
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ })
+ await IndexingService._set_job_state(tenant_id, redis_url, state)
+ return
+
+ ids_q = select(Message.id).where(
+ Message.tenant_id == tenant_id,
+ Message.parent_message_id.is_(None),
+ )
+ if mode == "differential":
+ ids_q = ids_q.where(Message.search_vector.is_(None))
+
+ ids_result = await db.execute(ids_q)
+ all_ids = [str(row[0]) for row in ids_result.fetchall()]
+
+ processed = 0
+ for batch_start in range(0, len(all_ids), BATCH_SIZE):
+ if await IndexingService._check_cancel(tenant_id, redis_url):
+ log.info(f"Reindex {mode} annullato al batch {batch_start}")
+ state.update({
+ "status": "cancelled",
+ "processed": processed,
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ })
+ await IndexingService._set_job_state(tenant_id, redis_url, state)
+ return
+
+ batch_ids = all_ids[batch_start: batch_start + BATCH_SIZE]
+
+ await db.execute(
+ text("""
+ UPDATE messages
+ SET search_vector =
+ setweight(to_tsvector('italian', coalesce(subject, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') ||
+ setweight(to_tsvector('simple',
+ coalesce(array_to_string(to_addresses, ' '), '')), 'B') ||
+ setweight(to_tsvector('italian', coalesce(body_text, '')), 'C') ||
+ setweight(to_tsvector('italian', coalesce((
+ SELECT string_agg(a.extracted_text, ' ')
+ FROM attachments a
+ WHERE a.message_id = messages.id
+ AND a.extracted_text IS NOT NULL
+ ), '')), 'D')
+ WHERE id = ANY(:ids)
+ """),
+ {"ids": batch_ids},
+ )
+ await db.commit()
+
+ processed += len(batch_ids)
+ state["processed"] = processed
+ await IndexingService._set_job_state(tenant_id, redis_url, state)
+
+ await asyncio.sleep(0.05)
+
+ state.update({
+ "status": "completed",
+ "processed": processed,
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ })
+ await IndexingService._set_job_state(tenant_id, redis_url, state)
+ log.info(f"Reindex {mode} completato: {processed}/{total} messaggi")
+
+ except Exception as exc:
+ log.error(f"Errore reindex {mode} tenant {tenant_id}: {exc}", exc_info=True)
+ state.update({
+ "status": "failed",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ "error": str(exc),
+ })
+ await IndexingService._set_job_state(tenant_id, redis_url, state)
+ finally:
+ await engine.dispose()
+
+ # ── Logica interna rescan allegati ────────────────────────────────────────
+
+ @staticmethod
+ async def _run_rescan_bg(
+ tenant_id: uuid.UUID,
+ force: bool,
+ redis_url: str,
+ db_url: str,
+ ) -> None:
+ """
+ Coroutine di rescan allegati eseguita in background.
+
+ Algoritmo:
+ 1. Trova gli allegati del tenant con formato supportato
+ (solo quelli con extracted_text IS NULL se force=False)
+ 2. Per ogni batch: scarica da MinIO, estrae testo, aggiorna extracted_text
+ 3. Dopo ogni batch: ricostruisce search_vector per i messaggi interessati
+ 4. Aggiorna progresso in Redis dopo ogni batch
+ 5. Controlla flag di cancellazione tra un batch e l'altro
+ """
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+ from sqlalchemy.orm import sessionmaker
+
+ log = logging.getLogger(__name__)
+ mode_str = "force" if force else "differential"
+ log.info(f"Avvio rescan allegati {mode_str} per tenant {tenant_id}")
+
+ engine = create_async_engine(db_url, echo=False)
+ AsyncSessionFactory = sessionmaker( # type: ignore[call-overload]
+ engine, class_=AsyncSession, expire_on_commit=False
+ )
+
+ state: dict = {
+ "status": "running",
+ "mode": mode_str,
+ "total": 0,
+ "processed": 0,
+ "started_at": None,
+ "finished_at": None,
+ "started_by": None,
+ "error": None,
+ }
+
+ try:
+ async with AsyncSessionFactory() as db:
+ from app.config import get_settings
+ from app.models.message import Attachment
+
+ settings = get_settings()
+
+ # ── 1. Conta allegati da processare ───────────────────────────
+ from sqlalchemy import or_
+
+ ext_conditions = [
+ Attachment.filename.ilike(f"%.{e}") for e in _SUPPORTED_EXTENSIONS
+ ]
+ base_filter = [
+ Attachment.tenant_id == tenant_id,
+ or_(
+ Attachment.content_type.in_(list(_SUPPORTED_CONTENT_TYPES)),
+ *ext_conditions,
+ ),
+ ]
+ if not force:
+ base_filter.append(Attachment.extracted_text.is_(None))
+
+ count_q = await db.execute(
+ select(func.count(Attachment.id)).where(*base_filter)
+ )
+ total: int = count_q.scalar_one()
+
+ # Aggiorna totale in Redis
+ import redis.asyncio as aioredis
+ redis_client = aioredis.from_url(redis_url, decode_responses=True)
+ try:
+ raw = await redis_client.get(_redis_rescan_key(tenant_id))
+ if raw:
+ state = json.loads(raw)
+ state["total"] = total
+ state["processed"] = 0
+ await redis_client.setex(
+ _redis_rescan_key(tenant_id),
+ REDIS_TTL_STATUS,
+ json.dumps(state, default=str),
+ )
+ finally:
+ await redis_client.aclose()
+
+ log.info(f"Rescan {mode_str}: {total} allegati da processare")
+
+ if total == 0:
+ state.update({
+ "status": "completed",
+ "processed": 0,
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ })
+ await IndexingService._set_state(
+ _redis_rescan_key(tenant_id), redis_url, state
+ )
+ return
+
+ # ── 2. Recupera IDs allegati da processare ────────────────────
+ ids_q = select(Attachment.id).where(*base_filter)
+ ids_result = await db.execute(ids_q)
+ all_att_ids = [row[0] for row in ids_result.fetchall()]
+
+ # ── 3. Crea client MinIO ───────────────────────────────────────
+ try:
+ from miniopy_async import Minio # type: ignore[import]
+ minio = Minio(
+ endpoint=settings.minio_endpoint,
+ access_key=settings.minio_access_key,
+ secret_key=settings.minio_secret_key,
+ secure=settings.minio_use_ssl,
+ )
+ bucket = settings.minio_bucket
+ except Exception as e:
+ raise RuntimeError(f"Impossibile creare client MinIO: {e}") from e
+
+ # ── 4. Processa in batch ───────────────────────────────────────
+ processed = 0
+ for batch_start in range(0, len(all_att_ids), RESCAN_BATCH_SIZE):
+ # Controlla cancellazione
+ if await IndexingService._check_cancel_flag(
+ _redis_rescan_cancel_key(tenant_id), redis_url
+ ):
+ log.info(f"Rescan {mode_str} annullato al batch {batch_start}")
+ state.update({
+ "status": "cancelled",
+ "processed": processed,
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ })
+ await IndexingService._set_state(
+ _redis_rescan_key(tenant_id), redis_url, state
+ )
+ return
+
+ batch_ids = all_att_ids[batch_start: batch_start + RESCAN_BATCH_SIZE]
+
+ # Carica allegati del batch
+ att_result = await db.execute(
+ select(Attachment).where(Attachment.id.in_(batch_ids))
+ )
+ attachments = list(att_result.scalars().all())
+
+ affected_message_ids: set[str] = set()
+
+ for att in attachments:
+ extractor = _resolve_extractor(att.content_type, att.filename)
+ if extractor is None:
+ continue
+
+ try:
+ response = await minio.get_object(bucket, att.storage_path)
+ content = await response.content.read()
+ response.close()
+ except Exception as e:
+ log.warning(
+ f"Impossibile scaricare allegato {att.id} "
+ f"({att.filename!r}) da MinIO: {e}"
+ )
+ continue
+
+ try:
+ e_name = _ext(att.filename)
+ if e_name == "p7m":
+ extracted = _extract_p7m(content, att.filename)
+ else:
+ extracted = extractor(content) # type: ignore[operator]
+ except Exception as ex:
+ log.debug(f"Errore estrazione {att.filename!r}: {ex}")
+ continue
+
+ if not extracted or not extracted.strip():
+ continue
+
+ att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN]
+ affected_message_ids.add(str(att.message_id))
+ log.debug(
+ f"Testo estratto da {att.filename!r}: "
+ f"{len(att.extracted_text)} caratteri"
+ )
+
+ await db.flush()
+
+ # Ricostruisce search_vector per i messaggi interessati
+ if affected_message_ids:
+ await db.execute(
+ text("""
+ UPDATE messages
+ SET search_vector =
+ setweight(to_tsvector('italian', coalesce(subject, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') ||
+ setweight(to_tsvector('simple',
+ coalesce(array_to_string(to_addresses, ' '), '')), 'B') ||
+ setweight(to_tsvector('italian', coalesce(body_text, '')), 'C') ||
+ setweight(to_tsvector('italian', coalesce((
+ SELECT string_agg(a.extracted_text, ' ')
+ FROM attachments a
+ WHERE a.message_id = messages.id
+ AND a.extracted_text IS NOT NULL
+ ), '')), 'D')
+ WHERE id = ANY(:ids)
+ """),
+ {"ids": list(affected_message_ids)},
+ )
+
+ await db.commit()
+
+ processed += len(batch_ids)
+ state["processed"] = processed
+ await IndexingService._set_state(
+ _redis_rescan_key(tenant_id), redis_url, state
+ )
+
+ await asyncio.sleep(0.1)
+
+ # ── 5. Completato ──────────────────────────────────────────────
+ state.update({
+ "status": "completed",
+ "processed": processed,
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ })
+ await IndexingService._set_state(
+ _redis_rescan_key(tenant_id), redis_url, state
+ )
+ log.info(f"Rescan {mode_str} completato: {processed}/{total} allegati")
+
+ except Exception as exc:
+ log.error(f"Errore rescan tenant {tenant_id}: {exc}", exc_info=True)
+ state.update({
+ "status": "failed",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ "error": str(exc),
+ })
+ await IndexingService._set_state(
+ _redis_rescan_key(tenant_id), redis_url, state
+ )
+ finally:
+ await engine.dispose()
diff --git a/backend/app/services/mailbox_service.py b/backend/app/services/mailbox_service.py
index 17c6cc3..94d2601 100644
--- a/backend/app/services/mailbox_service.py
+++ b/backend/app/services/mailbox_service.py
@@ -12,6 +12,7 @@ from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
from app.core.security import decrypt_credential, encrypt_credential
from app.models.mailbox import Mailbox
from app.models.tenant import Tenant
+from app.services.audit_service import log_audit
from app.schemas.mailbox import (
ConnectionTestRequest,
ConnectionTestResult,
@@ -85,6 +86,15 @@ class MailboxService:
)
self.db.add(mailbox)
await self.db.flush()
+ await log_audit(
+ self.db,
+ "mailbox.created",
+ tenant_id=tenant_id,
+ user_id=created_by,
+ resource_type="mailbox",
+ resource_id=mailbox.id,
+ payload={"email_address": mailbox.email_address},
+ )
return mailbox
async def list_mailboxes(
@@ -175,6 +185,14 @@ class MailboxService:
mailbox.status = "active"
await self.db.flush()
+ await log_audit(
+ self.db,
+ "mailbox.updated",
+ tenant_id=tenant_id,
+ resource_type="mailbox",
+ resource_id=mailbox_id,
+ payload={"mailbox_id": str(mailbox_id)},
+ )
return mailbox
async def delete_mailbox(
@@ -184,8 +202,17 @@ class MailboxService:
) -> None:
"""Soft-delete: imposta status=deleted."""
mailbox = await self.get_mailbox(mailbox_id, tenant_id)
+ email = mailbox.email_address
mailbox.status = "deleted"
await self.db.flush()
+ await log_audit(
+ self.db,
+ "mailbox.deleted",
+ tenant_id=tenant_id,
+ resource_type="mailbox",
+ resource_id=mailbox_id,
+ payload={"email_address": email},
+ )
# ─── Decrypt helpers (usati internamente e dal worker) ───────────────────
diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py
index 699f397..1875d3b 100644
--- a/backend/app/services/notification_service.py
+++ b/backend/app/services/notification_service.py
@@ -1,20 +1,24 @@
"""
-Servizio Notifiche Multi-canale – CRUD canali, regole, log.
+Servizio Notifiche Multi-canale – CRUD canali, regole, log + dispatch.
-Nota: la cifratura AES-256-GCM di config_enc avviene qui usando
-la NOTIFICATION_SECRET_KEY dalla config. Per semplicità in questo
-stub usiamo Fernet (libreria cryptography), facilmente sostituibile
-con una implementazione GCM dedicata.
+Cifratura: AES-256-GCM via libreria cryptography.
+ Formato config_enc: base64( nonce(12) || ciphertext+tag )
+ Chiave: ENCRYPTION_KEY (hex 64 char = 32 byte) dalla config.
+
+Backward compatibility: se il valore non decrittografa come GCM, viene
+tentato il fallback a base64 grezzo (configurazioni precedenti al fix).
"""
import base64
import json
+import logging
+import os
import uuid
from datetime import datetime, timezone
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.core.exceptions import NotFoundError
@@ -27,20 +31,53 @@ from app.schemas.notification import (
NotificationRuleUpdate,
)
+logger = logging.getLogger(__name__)
settings = get_settings()
-def _encrypt(data: dict) -> str:
- """Cifra un dict JSON → base64. Usa la SECRET_KEY come seed."""
- # In produzione: usa AES-256-GCM. Qui: semplice base64 con marker.
- raw = json.dumps(data).encode()
- return base64.b64encode(raw).decode()
+# ─── Cifratura AES-256-GCM ────────────────────────────────────────────────────
+
+def _encrypt(data: dict, key: bytes | None = None) -> str:
+ """
+ Cifra un dict JSON con AES-256-GCM.
+
+ Formato output: base64( nonce(12 byte) || ciphertext+tag(16 byte) )
+ """
+ if key is None:
+ key = settings.encryption_key_bytes
+ nonce = os.urandom(12)
+ aesgcm = AESGCM(key)
+ plaintext = json.dumps(data, ensure_ascii=False).encode("utf-8")
+ ciphertext = aesgcm.encrypt(nonce, plaintext, None) # include tag
+ return base64.b64encode(nonce + ciphertext).decode("ascii")
-def _decrypt(enc: str) -> dict:
- """Decifra il valore restituito da _encrypt."""
- raw = base64.b64decode(enc.encode())
- return json.loads(raw.decode())
+def _decrypt(enc: str, key: bytes | None = None) -> dict:
+ """
+ Decifra il valore prodotto da _encrypt.
+
+ Backward compatible: se il dato non e' GCM valido, prova il
+ vecchio base64 grezzo (usato prima del fix di sicurezza).
+ """
+ if key is None:
+ key = settings.encryption_key_bytes
+ try:
+ raw = base64.b64decode(enc.encode("ascii"))
+ if len(raw) > 28: # 12 nonce + 16 tag minimo
+ nonce = raw[:12]
+ ciphertext = raw[12:]
+ aesgcm = AESGCM(key)
+ plaintext = aesgcm.decrypt(nonce, ciphertext, None)
+ return json.loads(plaintext.decode("utf-8"))
+ except Exception:
+ pass
+
+ # Fallback: base64 grezzo (configurazioni create prima del fix GCM)
+ try:
+ raw = base64.b64decode(enc.encode("ascii"))
+ return json.loads(raw.decode("utf-8"))
+ except Exception:
+ return {}
class NotificationService:
@@ -113,6 +150,7 @@ class NotificationService:
if data.config is not None:
channel.config = data.config
if data.config_secret is not None:
+ # Re-cifra sempre con AES-256-GCM (aggiorna anche i vecchi base64)
channel.config_enc = _encrypt(data.config_secret)
await self.db.flush()
@@ -128,18 +166,16 @@ class NotificationService:
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
) -> ChannelTestResult:
"""
- Invia un messaggio di test al canale configurato.
+ Invia un messaggio di test reale al canale configurato.
- Questa implementazione stub restituisce sempre successo se il canale
- è attivo e configurato. Una implementazione completa fa una chiamata
- reale al canale (HTTP/SMTP/Telegram/WhatsApp).
+ Esegue invio effettivo per Telegram, Webhook, Email SMTP e WhatsApp.
"""
channel = await self.get_channel(channel_id, tenant_id)
if not channel.is_active:
return ChannelTestResult(
success=False,
- message="Il canale è disabilitato",
+ message="Il canale e' disabilitato",
)
if channel.circuit_open_until and channel.circuit_open_until > datetime.now(timezone.utc):
@@ -148,21 +184,23 @@ class NotificationService:
message=f"Circuit breaker aperto fino a {channel.circuit_open_until.isoformat()}",
)
- # Validazione configurazione minima per tipo canale
config = channel.config or {}
+ secret = {}
+ if channel.config_enc:
+ try:
+ secret = _decrypt(channel.config_enc)
+ except Exception as e:
+ return ChannelTestResult(
+ success=False,
+ message=f"Errore decifratura configurazione sensibile: {e}",
+ )
+
channel_type = channel.channel_type
- if channel_type == "webhook":
- if not config.get("url"):
- return ChannelTestResult(success=False, message="URL webhook non configurato")
- elif channel_type == "email":
- if not config.get("to_email"):
- return ChannelTestResult(success=False, message="Email destinatario non configurata")
- elif channel_type == "telegram":
+ # ── Telegram ──────────────────────────────────────────────────────────
+ if channel_type == "telegram":
if not config.get("chat_id"):
return ChannelTestResult(success=False, message="Chat ID Telegram non configurato")
- # Invio reale via Bot API
- secret = _decrypt(channel.config_enc) if channel.config_enc else {}
bot_token = secret.get("bot_token")
if not bot_token:
return ChannelTestResult(success=False, message="Bot token Telegram non configurato")
@@ -176,28 +214,128 @@ class NotificationService:
msg_id = result.get("message_id")
return ChannelTestResult(
success=True,
- message=f"Messaggio Telegram inviato con successo (message_id={msg_id}).",
+ message=f"Messaggio Telegram inviato (message_id={msg_id}).",
http_status=200,
)
- except TelegramError as exc:
- return ChannelTestResult(
- success=False,
- message=f"Errore Telegram: {exc}",
- http_status=exc.http_status,
- )
except Exception as exc:
return ChannelTestResult(
success=False,
- message=f"Errore imprevisto durante il test Telegram: {exc}",
+ message=f"Errore Telegram: {exc}",
)
- elif channel_type == "whatsapp":
- if not config.get("phone_number"):
- return ChannelTestResult(success=False, message="Numero WhatsApp non configurato")
+ # ── Webhook ───────────────────────────────────────────────────────────
+ elif channel_type == "webhook":
+ url = config.get("url")
+ if not url:
+ return ChannelTestResult(success=False, message="URL webhook non configurato")
+ webhook_secret = secret.get("webhook_secret")
+ try:
+ from app.notifications.webhook import WebhookError, send_test_webhook
+ result = await send_test_webhook(
+ url=url,
+ webhook_secret=webhook_secret,
+ channel_name=channel.name,
+ )
+ return ChannelTestResult(
+ success=True,
+ message=(
+ f"Webhook raggiunto con successo "
+ f"(HTTP {result['http_status']}, delivery={result['delivery_id']})."
+ ),
+ http_status=result["http_status"],
+ )
+ except Exception as exc:
+ http_status = getattr(exc, "http_status", None)
+ return ChannelTestResult(
+ success=False,
+ message=f"Errore webhook: {exc}",
+ http_status=http_status,
+ )
+
+ # ── Email SMTP ────────────────────────────────────────────────────────
+ elif channel_type == "email":
+ smtp_host = config.get("smtp_host")
+ smtp_port = config.get("smtp_port", 465)
+ from_email = config.get("from_email")
+ to_email = config.get("to_email")
+ smtp_user = config.get("smtp_user") or from_email
+ use_tls = config.get("smtp_use_tls", True)
+ use_starttls = config.get("smtp_use_starttls", False)
+ from_name = config.get("from_name", "PEChub Notifiche")
+ smtp_password = secret.get("smtp_password", "")
+
+ if not smtp_host:
+ return ChannelTestResult(success=False, message="Host SMTP non configurato")
+ if not from_email:
+ return ChannelTestResult(success=False, message="Email mittente non configurata")
+ if not to_email:
+ return ChannelTestResult(success=False, message="Email destinatario non configurata")
+ if not smtp_password:
+ return ChannelTestResult(success=False, message="Password SMTP non configurata")
+
+ try:
+ from app.notifications.email_smtp import EmailSMTPError, send_test_email
+ await send_test_email(
+ smtp_host=smtp_host,
+ smtp_port=int(smtp_port),
+ smtp_user=smtp_user,
+ smtp_password=smtp_password,
+ from_email=from_email,
+ to_email=to_email,
+ channel_name=channel.name,
+ from_name=from_name,
+ use_tls=use_tls,
+ use_starttls=use_starttls,
+ )
+ return ChannelTestResult(
+ success=True,
+ message=f"Email di test inviata con successo a {to_email}.",
+ http_status=200,
+ )
+ except Exception as exc:
+ return ChannelTestResult(
+ success=False,
+ message=f"Errore email: {exc}",
+ )
+
+ # ── WhatsApp ──────────────────────────────────────────────────────────
+ elif channel_type == "whatsapp":
+ phone_number_id = config.get("phone_number_id")
+ to_phone = config.get("to_phone")
+ access_token = secret.get("access_token")
+
+ if not phone_number_id:
+ return ChannelTestResult(success=False, message="phone_number_id non configurato")
+ if not to_phone:
+ return ChannelTestResult(success=False, message="Numero WhatsApp destinatario non configurato")
+ if not access_token:
+ return ChannelTestResult(success=False, message="Access token Meta non configurato")
+
+ try:
+ from app.notifications.whatsapp import WhatsAppError, send_test_whatsapp
+ result = await send_test_whatsapp(
+ phone_number_id=phone_number_id,
+ to_phone=to_phone,
+ access_token=access_token,
+ channel_name=channel.name,
+ )
+ return ChannelTestResult(
+ success=True,
+ message=f"Messaggio WhatsApp inviato (message_id={result.get('message_id')}).",
+ http_status=200,
+ )
+ except Exception as exc:
+ http_status = getattr(exc, "http_status", None)
+ return ChannelTestResult(
+ success=False,
+ message=f"Errore WhatsApp: {exc}",
+ http_status=http_status,
+ )
+
+ # ── Tipo sconosciuto ──────────────────────────────────────────────────
return ChannelTestResult(
- success=True,
- message=f"Canale {channel_type} configurato correttamente. Test simulato con successo.",
- http_status=200,
+ success=False,
+ message=f"Tipo canale '{channel_type}' non supportato",
)
# ─── Rules ───────────────────────────────────────────────────────────────
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/permission_service.py b/backend/app/services/permission_service.py
index e35ab1d..c458eb1 100644
--- a/backend/app/services/permission_service.py
+++ b/backend/app/services/permission_service.py
@@ -27,9 +27,13 @@ class PermissionService:
async def get_visible_mailboxes(
self, user: User
) -> list[uuid.UUID]:
- """Restituisce gli UUID delle caselle visibili all'utente."""
- if user.role in ("super_admin", "admin"):
- # Admin vede tutte le caselle del tenant
+ """Restituisce gli UUID delle caselle visibili all'utente.
+
+ Admin e supervisor vedono tutte le caselle del tenant.
+ Operator e readonly vedono solo le caselle con can_read=True esplicito.
+ """
+ if user.role in ("super_admin", "admin", "supervisor"):
+ # Admin e supervisor vedono tutte le caselle del tenant
result = await self.db.execute(
select(Mailbox.id).where(
Mailbox.tenant_id == user.tenant_id,
@@ -38,7 +42,7 @@ class PermissionService:
)
return [row[0] for row in result.all()]
- # Operatori: solo caselle con can_read=True
+ # Operator e readonly: solo caselle con can_read=True esplicito
result = await self.db.execute(
select(MailboxPermission.mailbox_id).where(
MailboxPermission.user_id == user.id,
@@ -50,9 +54,13 @@ class PermissionService:
async def check_can_read(
self, user: User, mailbox_id: uuid.UUID
) -> bool:
- """Verifica se l'utente può leggere i messaggi della casella."""
- if user.role in ("super_admin", "admin"):
- # Verifica solo che la casella appartenga al tenant
+ """Verifica se l'utente puo' leggere i messaggi della casella.
+
+ Admin e supervisor hanno accesso implicito a tutte le caselle del tenant.
+ Operator e readonly richiedono permesso esplicito can_read.
+ """
+ if user.role in ("super_admin", "admin", "supervisor"):
+ # Admin e supervisor: verifica solo che la casella appartenga al tenant
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id)
@@ -62,12 +70,15 @@ class PermissionService:
self, user: User, mailbox_id: uuid.UUID
) -> bool:
"""
- Verifica se l'utente può inviare dalla casella.
+ Verifica se l'utente puo' inviare dalla casella.
- L'accesso in invio è concesso se:
- 1. L'utente è admin del tenant, oppure
+ L'accesso in invio e' concesso se:
+ 1. L'utente e' admin del tenant, oppure
2. L'utente ha un permesso diretto can_send sulla casella, oppure
- 3. L'utente è assegnato a una Virtual Box attiva che include la casella.
+ 3. L'utente e' assegnato a una Virtual Box attiva che include la casella.
+
+ Nota: il supervisor NON ha invio implicito – richiede can_send esplicito
+ come operator, ma diversamente da operator vede tutte le caselle.
"""
if user.role in ("super_admin", "admin"):
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
@@ -89,6 +100,22 @@ class PermissionService:
perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_manage
+ async def check_can_conserve(
+ self, user: User, mailbox_id: uuid.UUID
+ ) -> bool:
+ """Verifica se l'utente puo' spostare messaggi nella cartella Conservazione.
+
+ Admin/super_admin: accesso implicito sempre.
+ Supervisor: richiede permesso esplicito can_conserve=True.
+ Operator/readonly: non autorizzati (richiedono permesso esplicito).
+ """
+ if user.role in ("super_admin", "admin"):
+ return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
+
+ # Supervisor, operator e readonly richiedono record esplicito
+ perm = await self._get_permission(user.id, mailbox_id)
+ return perm is not None and perm.can_conserve
+
async def require_can_read(self, user: User, mailbox_id: uuid.UUID) -> None:
"""Solleva 403 se l'utente non può leggere."""
if not await self.check_can_read(user, mailbox_id):
@@ -98,6 +125,11 @@ class PermissionService:
if not await self.check_can_send(user, mailbox_id):
raise PermissionDeniedError("casella (invio)")
+ async def require_can_conserve(self, user: User, mailbox_id: uuid.UUID) -> None:
+ """Solleva 403 se l'utente non puo' spostare messaggi in Conservazione."""
+ if not await self.check_can_conserve(user, mailbox_id):
+ raise PermissionDeniedError("casella (conservazione)")
+
# ─── CRUD permessi ────────────────────────────────────────────────────────
async def grant_permission(
@@ -134,6 +166,7 @@ class PermissionService:
existing.can_read = data.can_read
existing.can_send = data.can_send
existing.can_manage = data.can_manage
+ existing.can_conserve = data.can_conserve
existing.granted_by = granted_by.id
return existing
@@ -144,6 +177,7 @@ class PermissionService:
can_read=data.can_read,
can_send=data.can_send,
can_manage=data.can_manage,
+ can_conserve=data.can_conserve,
granted_by=granted_by.id,
)
self.db.add(perm)
@@ -190,6 +224,7 @@ class PermissionService:
"can_read": perm.can_read,
"can_send": perm.can_send,
"can_manage": perm.can_manage,
+ "can_conserve": perm.can_conserve,
"granted_at": perm.granted_at,
}
for perm, user in rows
@@ -217,6 +252,7 @@ class PermissionService:
"can_read": perm.can_read,
"can_send": perm.can_send,
"can_manage": perm.can_manage,
+ "can_conserve": perm.can_conserve,
}
for perm, mailbox in rows
]
diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py
new file mode 100644
index 0000000..3192a10
--- /dev/null
+++ b/backend/app/services/report_service.py
@@ -0,0 +1,594 @@
+"""
+ReportService – calcola KPI, serie storiche e produce export CSV/PDF.
+
+Non richiede migrazioni: lavora sulle tabelle messages e mailboxes esistenti.
+"""
+
+import csv
+import io
+import uuid
+from datetime import date, datetime, timedelta, timezone
+from typing import AsyncGenerator, Optional
+
+from sqlalchemy import case, func, select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.logging import get_logger
+from app.models.mailbox import Mailbox
+from app.models.message import Message
+from app.schemas.reports import (
+ DailyStat,
+ KpiSummary,
+ MailboxStat,
+ OutboundStateStat,
+ ReportSummaryResponse,
+)
+
+logger = get_logger(__name__)
+
+
+class ReportService:
+ def __init__(self, db: AsyncSession) -> None:
+ self.db = db
+
+ # ─── KPI principali ──────────────────────────────────────────────────────
+
+ async def get_summary(
+ self,
+ tenant_id: uuid.UUID,
+ period_days: int = 7,
+ visible_mailbox_ids: Optional[list[uuid.UUID]] = None,
+ ) -> ReportSummaryResponse:
+ """
+ Restituisce il riepilogo completo per la dashboard.
+
+ visible_mailbox_ids: se None l'utente e admin e vede tutto il tenant,
+ altrimenti filtra sulle caselle accessibili.
+ """
+ now = datetime.now(timezone.utc)
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ d7_start = now - timedelta(days=7)
+ d30_start = now - timedelta(days=30)
+
+ # ── Filtro base tenant + caselle visibili ────────────────────────────
+ def _base(q):
+ q = q.where(Message.tenant_id == tenant_id)
+ if visible_mailbox_ids is not None:
+ if not visible_mailbox_ids:
+ # Nessuna casella visibile: ritorna subito valori zero
+ return None
+ q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
+ return q
+
+ async def _count(q) -> int:
+ q = _base(q)
+ if q is None:
+ return 0
+ r = await self.db.execute(q)
+ return r.scalar_one() or 0
+
+ # PEC ricevute
+ received_today = await _count(
+ select(func.count(Message.id)).where(
+ Message.direction == "inbound",
+ Message.received_at >= today_start,
+ )
+ )
+ received_7d = await _count(
+ select(func.count(Message.id)).where(
+ Message.direction == "inbound",
+ Message.received_at >= d7_start,
+ )
+ )
+ received_30d = await _count(
+ select(func.count(Message.id)).where(
+ Message.direction == "inbound",
+ Message.received_at >= d30_start,
+ )
+ )
+
+ # PEC inviate
+ sent_today = await _count(
+ select(func.count(Message.id)).where(
+ Message.direction == "outbound",
+ Message.parent_message_id.is_(None),
+ Message.sent_at >= today_start,
+ )
+ )
+ sent_7d = await _count(
+ select(func.count(Message.id)).where(
+ Message.direction == "outbound",
+ Message.parent_message_id.is_(None),
+ Message.sent_at >= d7_start,
+ )
+ )
+ sent_30d = await _count(
+ select(func.count(Message.id)).where(
+ Message.direction == "outbound",
+ Message.parent_message_id.is_(None),
+ Message.sent_at >= d30_start,
+ )
+ )
+
+ # Anomalie (outbound con state=anomaly, senza genitore)
+ anomalie = await _count(
+ select(func.count(Message.id)).where(
+ Message.direction == "outbound",
+ Message.parent_message_id.is_(None),
+ Message.state == "anomaly",
+ )
+ )
+
+ # Tasso consegna: delivered / (delivered + anomaly + failed)
+ delivered = await _count(
+ select(func.count(Message.id)).where(
+ Message.direction == "outbound",
+ Message.parent_message_id.is_(None),
+ Message.state == "delivered",
+ )
+ )
+ failed = await _count(
+ select(func.count(Message.id)).where(
+ Message.direction == "outbound",
+ Message.parent_message_id.is_(None),
+ Message.state.in_(["anomaly", "failed"]),
+ )
+ )
+ total_terminal = delivered + failed
+ tasso_consegna = round((delivered / total_terminal * 100), 1) if total_terminal > 0 else 0.0
+
+ # Non letti
+ non_letti = await _count(
+ select(func.count(Message.id)).where(
+ Message.direction == "inbound",
+ Message.is_read == False, # noqa: E712
+ Message.is_trashed == False, # noqa: E712
+ )
+ )
+
+ # Totale messaggi
+ totale = await _count(select(func.count(Message.id)))
+
+ # Caselle in errore (NON filtrato per visible_mailbox_ids – e una info admin)
+ caselle_errore_r = await self.db.execute(
+ select(func.count(Mailbox.id)).where(
+ Mailbox.tenant_id == tenant_id,
+ Mailbox.status == "error",
+ )
+ )
+ caselle_errore = caselle_errore_r.scalar_one() or 0
+
+ kpi = KpiSummary(
+ received_today=received_today,
+ sent_today=sent_today,
+ received_7d=received_7d,
+ sent_7d=sent_7d,
+ received_30d=received_30d,
+ sent_30d=sent_30d,
+ anomalie_attive=anomalie,
+ tasso_consegna=tasso_consegna,
+ caselle_in_errore=caselle_errore,
+ messaggi_non_letti=non_letti,
+ totale_messaggi=totale,
+ )
+
+ # ── Serie storica giornaliera ─────────────────────────────────────────
+ daily_stats = await self._get_daily_stats(tenant_id, period_days, visible_mailbox_ids)
+
+ # ── Distribuzione stati outbound ──────────────────────────────────────
+ outbound_states = await self._get_outbound_states(tenant_id, visible_mailbox_ids)
+
+ # ── Statistiche per casella ───────────────────────────────────────────
+ mailbox_stats = await self._get_mailbox_stats(tenant_id, visible_mailbox_ids)
+
+ return ReportSummaryResponse(
+ generated_at=now,
+ period_days=period_days,
+ kpi=kpi,
+ daily_stats=daily_stats,
+ outbound_states=outbound_states,
+ mailbox_stats=mailbox_stats,
+ )
+
+ # ─── Serie storica ────────────────────────────────────────────────────────
+
+ async def _get_daily_stats(
+ self,
+ tenant_id: uuid.UUID,
+ days: int,
+ visible_mailbox_ids: Optional[list[uuid.UUID]],
+ ) -> list[DailyStat]:
+ """Conta PEC ricevute e inviate per ciascuno degli ultimi `days` giorni."""
+ since = datetime.now(timezone.utc) - timedelta(days=days)
+
+ def _apply_filters(q):
+ q = q.where(Message.tenant_id == tenant_id)
+ if visible_mailbox_ids is not None:
+ if not visible_mailbox_ids:
+ return None
+ q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
+ return q
+
+ # Aggregazione ricevute per giorno
+ q_recv = (
+ select(
+ func.date_trunc("day", Message.received_at).label("day"),
+ func.count(Message.id).label("cnt"),
+ )
+ .where(
+ Message.direction == "inbound",
+ Message.received_at >= since,
+ )
+ .group_by(text("day"))
+ .order_by(text("day"))
+ )
+ q_recv = _apply_filters(q_recv)
+
+ # Aggregazione inviate per giorno
+ q_sent = (
+ select(
+ func.date_trunc("day", Message.sent_at).label("day"),
+ func.count(Message.id).label("cnt"),
+ )
+ .where(
+ Message.direction == "outbound",
+ Message.parent_message_id.is_(None),
+ Message.sent_at >= since,
+ )
+ .group_by(text("day"))
+ .order_by(text("day"))
+ )
+ q_sent = _apply_filters(q_sent)
+
+ recv_map: dict[date, int] = {}
+ sent_map: dict[date, int] = {}
+
+ if q_recv is not None:
+ r = await self.db.execute(q_recv)
+ for row in r.all():
+ if row.day:
+ d = row.day.date() if hasattr(row.day, "date") else row.day
+ recv_map[d] = row.cnt
+
+ if q_sent is not None:
+ r = await self.db.execute(q_sent)
+ for row in r.all():
+ if row.day:
+ d = row.day.date() if hasattr(row.day, "date") else row.day
+ sent_map[d] = row.cnt
+
+ # Costruisce la serie completa (tutti i giorni, anche quelli a zero)
+ result: list[DailyStat] = []
+ for i in range(days, -1, -1):
+ d = (datetime.now(timezone.utc) - timedelta(days=i)).date()
+ result.append(DailyStat(
+ day=d,
+ received=recv_map.get(d, 0),
+ sent=sent_map.get(d, 0),
+ ))
+ return result
+
+ # ─── Distribuzione stati outbound ────────────────────────────────────────
+
+ async def _get_outbound_states(
+ self,
+ tenant_id: uuid.UUID,
+ visible_mailbox_ids: Optional[list[uuid.UUID]],
+ ) -> list[OutboundStateStat]:
+ q = (
+ select(Message.state, func.count(Message.id).label("cnt"))
+ .where(
+ Message.tenant_id == tenant_id,
+ Message.direction == "outbound",
+ Message.parent_message_id.is_(None),
+ )
+ .group_by(Message.state)
+ )
+ if visible_mailbox_ids is not None:
+ if not visible_mailbox_ids:
+ return []
+ q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
+
+ r = await self.db.execute(q)
+ return [OutboundStateStat(state=row.state, count=row.cnt) for row in r.all()]
+
+ # ─── Statistiche per casella ──────────────────────────────────────────────
+
+ async def _get_mailbox_stats(
+ self,
+ tenant_id: uuid.UUID,
+ visible_mailbox_ids: Optional[list[uuid.UUID]],
+ ) -> list[MailboxStat]:
+ # Carica le caselle
+ mb_q = select(Mailbox).where(Mailbox.tenant_id == tenant_id)
+ if visible_mailbox_ids is not None:
+ if not visible_mailbox_ids:
+ return []
+ mb_q = mb_q.where(Mailbox.id.in_(visible_mailbox_ids))
+ mb_result = await self.db.execute(mb_q)
+ mailboxes = mb_result.scalars().all()
+
+ if not mailboxes:
+ return []
+
+ mailbox_ids = [m.id for m in mailboxes]
+ mailbox_map = {m.id: m for m in mailboxes}
+
+ # Aggregazione messaggi per casella e direction
+ agg_q = (
+ select(
+ Message.mailbox_id,
+ Message.direction,
+ Message.state,
+ Message.is_read,
+ func.count(Message.id).label("cnt"),
+ )
+ .where(
+ Message.tenant_id == tenant_id,
+ Message.mailbox_id.in_(mailbox_ids),
+ Message.parent_message_id.is_(None),
+ )
+ .group_by(Message.mailbox_id, Message.direction, Message.state, Message.is_read)
+ )
+ agg_result = await self.db.execute(agg_q)
+
+ # Accumula per casella
+ stats: dict[uuid.UUID, MailboxStat] = {}
+ for mb in mailboxes:
+ stats[mb.id] = MailboxStat(
+ mailbox_id=mb.id,
+ email_address=mb.email_address,
+ display_name=mb.display_name,
+ status=mb.status,
+ last_sync_at=mb.last_sync_at,
+ )
+
+ for row in agg_result.all():
+ s = stats.get(row.mailbox_id)
+ if not s:
+ continue
+ if row.direction == "inbound":
+ s.received_total += row.cnt
+ if not row.is_read:
+ s.non_letti += row.cnt
+ elif row.direction == "outbound":
+ s.sent_total += row.cnt
+ if row.state == "anomaly":
+ s.anomalie += row.cnt
+
+ # Ordina per volume decrescente
+ return sorted(
+ stats.values(),
+ key=lambda x: x.received_total + x.sent_total,
+ reverse=True,
+ )
+
+ # ─── Export CSV ───────────────────────────────────────────────────────────
+
+ async def export_csv(
+ self,
+ tenant_id: uuid.UUID,
+ date_from: Optional[datetime],
+ date_to: Optional[datetime],
+ mailbox_id: Optional[uuid.UUID],
+ visible_mailbox_ids: Optional[list[uuid.UUID]],
+ ) -> bytes:
+ """Genera un CSV con tutti i messaggi del periodo."""
+ q = (
+ select(
+ Message.id,
+ Message.direction,
+ Message.state,
+ Message.pec_type,
+ Message.subject,
+ Message.from_address,
+ Message.received_at,
+ Message.sent_at,
+ Message.size_bytes,
+ Message.is_read,
+ Message.has_attachments,
+ Mailbox.email_address.label("mailbox_email"),
+ )
+ .join(Mailbox, Message.mailbox_id == Mailbox.id)
+ .where(
+ Message.tenant_id == tenant_id,
+ Message.parent_message_id.is_(None),
+ )
+ .order_by(Message.received_at.desc().nullslast(), Message.created_at.desc())
+ )
+
+ if visible_mailbox_ids is not None:
+ if not visible_mailbox_ids:
+ buf = io.StringIO()
+ writer = csv.writer(buf)
+ writer.writerow(CSV_HEADERS)
+ return buf.getvalue().encode("utf-8-sig")
+ q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
+
+ if mailbox_id:
+ q = q.where(Message.mailbox_id == mailbox_id)
+
+ if date_from:
+ q = q.where(
+ (Message.received_at >= date_from) | (Message.sent_at >= date_from)
+ )
+ if date_to:
+ q = q.where(
+ (Message.received_at <= date_to) | (Message.sent_at <= date_to)
+ )
+
+ result = await self.db.execute(q)
+ rows = result.all()
+
+ buf = io.StringIO()
+ writer = csv.writer(buf)
+ writer.writerow(CSV_HEADERS)
+
+ for r in rows:
+ ts = r.received_at or r.sent_at
+ writer.writerow([
+ str(r.id),
+ r.mailbox_email or "",
+ r.direction or "",
+ r.state or "",
+ r.pec_type or "",
+ r.subject or "",
+ r.from_address or "",
+ ts.strftime("%Y-%m-%d %H:%M:%S") if ts else "",
+ r.size_bytes or "",
+ "Si" if r.is_read else "No",
+ "Si" if r.has_attachments else "No",
+ ])
+
+ return buf.getvalue().encode("utf-8-sig")
+
+ # ─── Export PDF ───────────────────────────────────────────────────────────
+
+ async def export_pdf(
+ self,
+ tenant_id: uuid.UUID,
+ date_from: Optional[datetime],
+ date_to: Optional[datetime],
+ visible_mailbox_ids: Optional[list[uuid.UUID]],
+ ) -> bytes:
+ """
+ Genera un PDF di riepilogo con KPI e tabella caselle.
+ Usa reportlab (puro Python, nessuna dipendenza di sistema).
+ """
+ try:
+ from reportlab.lib import colors
+ from reportlab.lib.pagesizes import A4
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+ from reportlab.lib.units import cm
+ from reportlab.platypus import (
+ Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
+ )
+ except ImportError:
+ raise RuntimeError(
+ "reportlab non installato. Aggiungere 'reportlab>=4.2.0' "
+ "alle dipendenze del backend."
+ )
+
+ summary = await self.get_summary(tenant_id, 30, visible_mailbox_ids)
+ now_str = summary.generated_at.strftime("%d/%m/%Y %H:%M")
+
+ buf = io.BytesIO()
+ doc = SimpleDocTemplate(
+ buf,
+ pagesize=A4,
+ leftMargin=2 * cm,
+ rightMargin=2 * cm,
+ topMargin=2 * cm,
+ bottomMargin=2 * cm,
+ )
+
+ styles = getSampleStyleSheet()
+ title_style = ParagraphStyle(
+ "Title", parent=styles["Title"], fontSize=18, spaceAfter=6,
+ )
+ subtitle_style = ParagraphStyle(
+ "Subtitle", parent=styles["Normal"], fontSize=10, textColor=colors.grey, spaceAfter=20,
+ )
+ heading_style = ParagraphStyle(
+ "Heading2", parent=styles["Heading2"], fontSize=13, spaceBefore=14, spaceAfter=6,
+ )
+
+ story = []
+
+ # Intestazione
+ story.append(Paragraph("PEChub – Report Attivita PEC", title_style))
+ date_range = ""
+ if date_from:
+ date_range += f"Dal {date_from.strftime('%d/%m/%Y')} "
+ if date_to:
+ date_range += f"Al {date_to.strftime('%d/%m/%Y')} "
+ story.append(Paragraph(
+ f"Generato il {now_str} {date_range}",
+ subtitle_style,
+ ))
+
+ # Sezione KPI
+ story.append(Paragraph("Indicatori Chiave (ultimi 30 giorni)", heading_style))
+ kpi = summary.kpi
+ kpi_data = [
+ ["Indicatore", "Valore"],
+ ["PEC ricevute oggi", str(kpi.received_today)],
+ ["PEC inviate oggi", str(kpi.sent_today)],
+ ["PEC ricevute (7 gg)", str(kpi.received_7d)],
+ ["PEC inviate (7 gg)", str(kpi.sent_7d)],
+ ["PEC ricevute (30 gg)", str(kpi.received_30d)],
+ ["PEC inviate (30 gg)", str(kpi.sent_30d)],
+ ["Anomalie attive", str(kpi.anomalie_attive)],
+ ["Tasso di consegna", f"{kpi.tasso_consegna}%"],
+ ["Caselle in errore", str(kpi.caselle_in_errore)],
+ ["Messaggi non letti", str(kpi.messaggi_non_letti)],
+ ]
+ kpi_table = Table(kpi_data, colWidths=[10 * cm, 5 * cm])
+ kpi_table.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e40af")),
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
+ ("FONTSIZE", (0, 0), (-1, -1), 10),
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f4ff")]),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
+ ("ALIGN", (1, 0), (1, -1), "RIGHT"),
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
+ ]))
+ story.append(kpi_table)
+ story.append(Spacer(1, 0.5 * cm))
+
+ # Sezione caselle
+ if summary.mailbox_stats:
+ story.append(Paragraph("Dettaglio per Casella", heading_style))
+ mb_header = ["Casella", "Stato", "Ricevute", "Inviate", "Anomalie", "Non letti"]
+ mb_data = [mb_header]
+ for ms in summary.mailbox_stats:
+ mb_data.append([
+ ms.email_address,
+ ms.status,
+ str(ms.received_total),
+ str(ms.sent_total),
+ str(ms.anomalie),
+ str(ms.non_letti),
+ ])
+ mb_table = Table(
+ mb_data,
+ colWidths=[6.5 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm],
+ )
+ mb_table.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e40af")),
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f4ff")]),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
+ ("ALIGN", (1, 0), (-1, -1), "RIGHT"),
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
+ ]))
+ story.append(mb_table)
+
+ doc.build(story)
+ return buf.getvalue()
+
+
+# ─── Costanti ────────────────────────────────────────────────────────────────
+
+CSV_HEADERS = [
+ "ID",
+ "Casella",
+ "Direzione",
+ "Stato",
+ "Tipo PEC",
+ "Oggetto",
+ "Mittente",
+ "Data/Ora",
+ "Dimensione (byte)",
+ "Letto",
+ "Allegati",
+]
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/search_service.py b/backend/app/services/search_service.py
new file mode 100644
index 0000000..9b9baf6
--- /dev/null
+++ b/backend/app/services/search_service.py
@@ -0,0 +1,139 @@
+"""
+Servizio di ricerca full-text per i messaggi PEC.
+
+Utilizza i vettori tsvector di PostgreSQL per ricerche veloci su:
+ - oggetto (peso A)
+ - mittente / destinatari (peso B)
+ - corpo del messaggio (peso C)
+ - testo estratto dagli allegati PDF/DOCX (peso D)
+
+Se search_vector e' NULL (messaggio non ancora indicizzato dal worker),
+cade back automaticamente a ILIKE sulle colonne base.
+"""
+
+import uuid
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import case, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.models.message import Message
+
+
+class SearchService:
+ """Incapsula la logica di ricerca full-text sui messaggi."""
+
+ def __init__(self, db: AsyncSession) -> None:
+ self.db = db
+
+ async def search_messages(
+ self,
+ tenant_id: uuid.UUID,
+ search_term: str,
+ visible_mailbox_ids: Optional[list[uuid.UUID]],
+ mailbox_id: Optional[uuid.UUID] = None,
+ direction: Optional[str] = None,
+ state: Optional[str] = None,
+ pec_type: Optional[str] = None,
+ date_from: Optional[datetime] = None,
+ date_to: Optional[datetime] = None,
+ is_archived: Optional[bool] = False,
+ is_trashed: Optional[bool] = False,
+ is_starred: Optional[bool] = None,
+ is_read: Optional[bool] = None,
+ page: int = 1,
+ page_size: int = 50,
+ ) -> tuple[list[Message], int]:
+ """
+ Ricerca full-text nei messaggi.
+
+ Logica:
+ 1. Messaggi con search_vector non NULL → usa @@ operator + ts_rank
+ 2. Messaggi con search_vector NULL → fallback ILIKE (non ancora indicizzati)
+ 3. Applica tutti i filtri aggiuntivi (data, stato, tipo, direzione, ecc.)
+ 4. Ordina per rilevanza FTS desc, poi per data desc
+ """
+ q = select(Message).where(
+ Message.tenant_id == tenant_id,
+ Message.parent_message_id.is_(None),
+ )
+
+ # Restrizione caselle visibili (permessi)
+ if visible_mailbox_ids is not None:
+ if not visible_mailbox_ids:
+ return [], 0
+ q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
+
+ # Filtri opzionali
+ if mailbox_id:
+ q = q.where(Message.mailbox_id == mailbox_id)
+ if direction:
+ q = q.where(Message.direction == direction)
+ if state:
+ q = q.where(Message.state == state)
+ if pec_type:
+ q = q.where(Message.pec_type == pec_type)
+ if is_archived is not None:
+ q = q.where(Message.is_archived == is_archived)
+ if is_trashed is not None:
+ q = q.where(Message.is_trashed == is_trashed)
+ if is_starred is not None:
+ q = q.where(Message.is_starred == is_starred)
+ if is_read is not None:
+ q = q.where(Message.is_read == is_read)
+
+ # Filtri data: cerca sia su received_at che su sent_at
+ if date_from:
+ q = q.where(
+ or_(
+ Message.received_at >= date_from,
+ Message.sent_at >= date_from,
+ )
+ )
+ if date_to:
+ q = q.where(
+ or_(
+ Message.received_at <= date_to,
+ Message.sent_at <= date_to,
+ )
+ )
+
+ # Full-text search con fallback ILIKE
+ tsquery = func.websearch_to_tsquery("italian", search_term)
+ term_like = f"%{search_term}%"
+
+ fts_condition = Message.search_vector.op("@@")(tsquery)
+ ilike_fallback = Message.search_vector.is_(None) & or_(
+ Message.subject.ilike(term_like),
+ Message.from_address.ilike(term_like),
+ Message.body_text.ilike(term_like),
+ )
+
+ q = q.where(or_(fts_condition, ilike_fallback))
+
+ # Conteggio totale (senza paginazione)
+ count_q = select(func.count()).select_from(q.subquery())
+ total: int = (await self.db.execute(count_q)).scalar_one()
+
+ # Ordinamento per rilevanza FTS, poi data
+ rank_expr = case(
+ (Message.search_vector.isnot(None), func.ts_rank(Message.search_vector, tsquery)),
+ else_=0.0,
+ )
+
+ q = (
+ q.options(selectinload(Message.labels))
+ .order_by(
+ rank_expr.desc(),
+ Message.received_at.desc().nullslast(),
+ Message.created_at.desc(),
+ )
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+
+ result = await self.db.execute(q)
+ items = list(result.scalars().all())
+ return items, total
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/backend/app/services/user_service.py b/backend/app/services/user_service.py
index 9b82426..eadd2f9 100644
--- a/backend/app/services/user_service.py
+++ b/backend/app/services/user_service.py
@@ -13,6 +13,7 @@ from app.core.security import hash_password
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.user import UserCreateRequest, UserUpdateRequest
+from app.services.audit_service import log_audit
class UserService:
@@ -61,6 +62,15 @@ class UserService:
)
self.db.add(user)
await self.db.flush() # ottieni l'ID
+ await log_audit(
+ self.db,
+ "user.created",
+ tenant_id=tenant_id,
+ user_id=created_by.id,
+ resource_type="user",
+ resource_id=user.id,
+ payload={"email": user.email, "role": user.role},
+ )
return user
async def get_user(self, user_id: uuid.UUID, tenant_id: uuid.UUID) -> User:
@@ -110,13 +120,26 @@ class UserService:
if user.is_super_admin and not updated_by.is_super_admin:
raise ForbiddenError("Non puoi modificare un super_admin")
+ changes: dict = {}
if data.full_name is not None:
+ changes["full_name"] = data.full_name
user.full_name = data.full_name
if data.role is not None:
+ changes["role"] = data.role
user.role = data.role
if data.is_active is not None:
+ changes["is_active"] = data.is_active
user.is_active = data.is_active
+ await log_audit(
+ self.db,
+ "user.updated",
+ tenant_id=tenant_id,
+ user_id=updated_by.id,
+ resource_type="user",
+ resource_id=user_id,
+ payload={"changes": changes},
+ )
return user
async def reset_password(
@@ -143,3 +166,12 @@ class UserService:
# Soft delete (disabilita invece di eliminare)
user.is_active = False
+ await log_audit(
+ self.db,
+ "user.deleted",
+ tenant_id=tenant_id,
+ user_id=deleted_by.id,
+ resource_type="user",
+ resource_id=user_id,
+ payload={"email": user.email},
+ )
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 792b925..ca5cc67 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -46,6 +46,15 @@ dependencies = [
# Storage MinIO/S3
"miniopy-async>=1.21.0",
+ # Estrazione testo allegati (usato anche dal job rescan nel backend)
+ "pypdf>=4.0.0",
+ "python-docx>=1.1.0",
+
+ # OCR per allegati image-only (immagini dirette e PDF scansionati)
+ "pytesseract>=0.3.13",
+ "pdf2image>=1.17.0",
+ "Pillow>=11.0.0",
+
# IMAP async (per test connessione nel backend + mailbox service)
"aioimaplib>=2.0.0",
@@ -58,6 +67,9 @@ dependencies = [
# Utilities
"python-multipart>=0.0.9", # upload file
"python-dotenv>=1.0.0",
+
+ # Generazione PDF report (puro Python, nessuna dipendenza di sistema)
+ "reportlab>=4.2.0",
]
[project.optional-dependencies]
diff --git a/frontend/node_modules/.package-lock.json b/frontend/node_modules/.package-lock.json
index 8a09e67..7acab78 100644
--- a/frontend/node_modules/.package-lock.json
+++ b/frontend/node_modules/.package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "pecflow-frontend",
+ "name": "pechub-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
@@ -251,6 +251,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -2797,6 +2806,69 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3378,6 +3450,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
@@ -3406,6 +3599,12 @@
}
}
},
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -3445,6 +3644,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3601,6 +3810,12 @@
"@types/estree": "^1.0.0"
}
},
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -3861,6 +4076,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -4000,6 +4224,12 @@
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4184,7 +4414,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4465,6 +4694,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
@@ -4763,6 +5009,12 @@
"react-dom": ">=16"
}
},
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4852,6 +5104,21 @@
"react-dom": ">=16.8"
}
},
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -4874,6 +5141,22 @@
}
}
},
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4897,6 +5180,38 @@
"node": ">=8.10.0"
}
},
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5161,6 +5476,12 @@
"node": ">=0.8"
}
},
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -5396,6 +5717,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1fcd11e..1df2a11 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "pecflow-frontend",
+ "name": "pechub-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "pecflow-frontend",
+ "name": "pechub-frontend",
"version": "1.0.0",
"dependencies": {
"@radix-ui/react-avatar": "^1.1.1",
@@ -42,6 +42,7 @@
"react-hook-form": "^7.53.0",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.26.2",
+ "recharts": "^2.13.0",
"tailwind-merge": "^2.5.2",
"zustand": "^5.0.0"
},
@@ -306,6 +307,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -3548,6 +3558,69 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4129,6 +4202,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
@@ -4157,6 +4351,12 @@
}
}
},
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -4196,6 +4396,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4352,6 +4562,12 @@
"@types/estree": "^1.0.0"
}
},
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -4627,6 +4843,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -4766,6 +4991,12 @@
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4950,7 +5181,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5231,6 +5461,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
@@ -5529,6 +5776,12 @@
"react-dom": ">=16"
}
},
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5618,6 +5871,21 @@
"react-dom": ">=16.8"
}
},
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -5640,6 +5908,22 @@
}
}
},
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -5663,6 +5947,38 @@
"node": ">=8.10.0"
}
},
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5927,6 +6243,12 @@
"node": ">=0.8"
}
},
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -6162,6 +6484,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 6f4ef33..8a2f74a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -48,6 +48,7 @@
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.26.2",
"tailwind-merge": "^2.5.2",
+ "recharts": "^2.13.0",
"zustand": "^5.0.0"
},
"devDependencies": {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3d79137..2fb28d2 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -11,6 +11,13 @@ import { SettingsPage } from '@/pages/Settings/SettingsPage'
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
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.
@@ -44,16 +51,22 @@ export default function App() {
} />
{/* Vista globale: tutte le caselle insieme */}
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
{/* Vista per singola casella PEC */}
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
{/* Vista per Virtual Box assegnata */}
} />
@@ -74,6 +87,21 @@ export default function App() {
{/* Super Admin – Gestione Multi-Tenant */}
} />
+ {/* Ricerca avanzata full-text */}
+ } />
+
+ {/* Dashboard e Reportistica */}
+ } />
+
+ {/* Audit Log */}
+ } />
+
+ {/* Nuove funzionalita' */}
+ } />
+ } />
+ } />
+ } />
+
{/* Profilo utente */}
} />
diff --git a/frontend/src/api/audit_log.api.ts b/frontend/src/api/audit_log.api.ts
new file mode 100644
index 0000000..01e66ee
--- /dev/null
+++ b/frontend/src/api/audit_log.api.ts
@@ -0,0 +1,41 @@
+/**
+ * Client API per Audit Log.
+ */
+
+import apiClient from './client'
+import type { PaginatedResponse } from '@/types/api.types'
+
+export interface AuditLogEntry {
+ id: number
+ tenant_id: string | null
+ user_id: string | null
+ action: string
+ resource_type: string | null
+ resource_id: string | null
+ ip_address: string | null
+ user_agent: string | null
+ payload: Record | null
+ outcome: 'success' | 'failure'
+ occurred_at: string
+}
+
+export type AuditLogListResponse = PaginatedResponse
+
+export interface AuditLogParams {
+ page?: number
+ page_size?: number
+ action?: string
+ user_id?: string
+ outcome?: 'success' | 'failure'
+ date_from?: string
+ date_to?: string
+ resource_type?: string
+ tenant_id?: string
+}
+
+export const auditLogApi = {
+ list: (params: AuditLogParams = {}): Promise =>
+ apiClient
+ .get('/audit-log', { params })
+ .then((r) => r.data),
+}
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/mailboxes.api.ts b/frontend/src/api/mailboxes.api.ts
index 08d5bfd..bd3fcda 100644
--- a/frontend/src/api/mailboxes.api.ts
+++ b/frontend/src/api/mailboxes.api.ts
@@ -28,4 +28,16 @@ export const mailboxesApi = {
apiClient
.post(`/mailboxes/${id}/test-connection`, { protocol })
.then((r) => r.data),
+
+ /** Forza una sincronizzazione IMAP manuale (accoda job arq). */
+ forceSync: (id: string) =>
+ apiClient
+ .post<{ status: string; mailbox_id: string; message: string }>(`/mailboxes/${id}/sync`)
+ .then((r) => r.data),
+
+ /** Restituisce il conteggio messaggi non letti per ciascuna casella. */
+ getUnreadCounts: () =>
+ apiClient
+ .get<{ counts: Record }>('/mailboxes/unread-counts')
+ .then((r) => r.data.counts),
}
diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts
index 0750c8f..364244d 100644
--- a/frontend/src/api/messages.api.ts
+++ b/frontend/src/api/messages.api.ts
@@ -13,16 +13,30 @@ export interface MessageFilters {
mailbox_id?: string
direction?: 'inbound' | 'outbound'
state?: string
+ pec_type?: string
is_read?: boolean
is_starred?: boolean
is_archived?: boolean
+ is_trashed?: boolean
+ /** Filtra per messaggi in attesa di conservazione (cartella Da Conservare) */
+ is_pending_conservation?: boolean
+ /** Filtra per messaggi gia' conservati (cartella Storico) */
+ is_conserved?: boolean
search?: string
+ /** Data minima nel formato ISO 8601 (es. "2026-01-01T00:00:00Z") */
+ date_from?: string
+ /** Data massima nel formato ISO 8601 */
+ date_to?: string
}
export interface MessageBulkUpdatePayload {
ids: string[]
+ is_read?: boolean
is_starred?: boolean
is_archived?: boolean
+ is_trashed?: boolean
+ is_pending_conservation?: boolean
+ is_conserved?: boolean
}
export interface MessageBulkUpdateResponse {
@@ -60,7 +74,29 @@ export const messagesApi = {
.patch(`/messages/${id}`, { is_archived: false })
.then((r) => r.data),
- /** Aggiorna in blocco is_starred e/o is_archived su più messaggi */
+ trash: (id: string) =>
+ apiClient
+ .patch(`/messages/${id}`, { is_trashed: true })
+ .then((r) => r.data),
+
+ untrash: (id: string) =>
+ apiClient
+ .patch(`/messages/${id}`, { is_trashed: false })
+ .then((r) => r.data),
+
+ /** Sposta un messaggio nella cartella Da Conservare */
+ conserve: (id: string) =>
+ apiClient
+ .patch(`/messages/${id}`, { is_pending_conservation: true })
+ .then((r) => r.data),
+
+ /** Rimuove un messaggio dalla cartella Da Conservare */
+ unconserve: (id: string) =>
+ apiClient
+ .patch(`/messages/${id}`, { is_pending_conservation: false })
+ .then((r) => r.data),
+
+ /** Aggiorna in blocco is_starred e/o is_archived e/o is_trashed su più messaggi */
bulkUpdate: (payload: MessageBulkUpdatePayload) =>
apiClient
.patch('/messages/bulk', payload)
@@ -91,4 +127,47 @@ 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).
+ */
+ downloadPackage: async (messageId: string, subject?: string | null): Promise => {
+ const response = await apiClient.get(
+ `/messages/${messageId}/download-package`,
+ { responseType: 'blob' },
+ )
+ const blobUrl = window.URL.createObjectURL(new Blob([response.data], { type: 'application/zip' }))
+ const anchor = document.createElement('a')
+ anchor.href = blobUrl
+ const safeSubject = (subject || 'pec').replace(/[/\\]/g, '_').slice(0, 50)
+ anchor.setAttribute('download', `pec_${safeSubject}.zip`)
+ document.body.appendChild(anchor)
+ anchor.click()
+ anchor.remove()
+ window.URL.revokeObjectURL(blobUrl)
+ },
}
diff --git a/frontend/src/api/reports.api.ts b/frontend/src/api/reports.api.ts
new file mode 100644
index 0000000..a44848f
--- /dev/null
+++ b/frontend/src/api/reports.api.ts
@@ -0,0 +1,99 @@
+/**
+ * API client per la Dashboard e Reportistica (Fase 7).
+ */
+
+import { apiClient } from './client'
+
+// ─── Tipi ─────────────────────────────────────────────────────────────────────
+
+export interface KpiSummary {
+ received_today: number
+ sent_today: number
+ received_7d: number
+ sent_7d: number
+ received_30d: number
+ sent_30d: number
+ anomalie_attive: number
+ tasso_consegna: number
+ caselle_in_errore: number
+ messaggi_non_letti: number
+ totale_messaggi: number
+}
+
+export interface DailyStat {
+ day: string // "YYYY-MM-DD"
+ received: number
+ sent: number
+}
+
+export interface OutboundStateStat {
+ state: string
+ count: number
+}
+
+export interface MailboxStat {
+ mailbox_id: string
+ email_address: string
+ display_name: string | null
+ status: string
+ received_total: number
+ sent_total: number
+ anomalie: number
+ non_letti: number
+ last_sync_at: string | null
+}
+
+export interface ReportSummaryResponse {
+ generated_at: string
+ period_days: number
+ kpi: KpiSummary
+ daily_stats: DailyStat[]
+ outbound_states: OutboundStateStat[]
+ mailbox_stats: MailboxStat[]
+}
+
+// ─── API ──────────────────────────────────────────────────────────────────────
+
+export const reportsApi = {
+ /**
+ * Recupera il riepilogo KPI + grafici per la dashboard.
+ * @param days Numero di giorni per la serie storica (default 7)
+ */
+ getSummary: async (days = 7): Promise => {
+ const res = await apiClient.get('/reports/summary', {
+ params: { days },
+ })
+ return res.data
+ },
+
+ /**
+ * Scarica il report in formato CSV.
+ * Il browser riceverà un file da scaricare.
+ */
+ exportCsv: (params?: {
+ date_from?: string
+ date_to?: string
+ mailbox_id?: string
+ }) => {
+ const url = new URL('/api/v1/reports/export', window.location.origin)
+ url.searchParams.set('format', 'csv')
+ if (params?.date_from) url.searchParams.set('date_from', params.date_from)
+ if (params?.date_to) url.searchParams.set('date_to', params.date_to)
+ if (params?.mailbox_id) url.searchParams.set('mailbox_id', params.mailbox_id)
+ return url.toString()
+ },
+
+ /**
+ * Scarica il report in formato PDF.
+ */
+ exportPdf: (params?: {
+ date_from?: string
+ date_to?: string
+ }) => {
+ const url = new URL('/api/v1/reports/export', window.location.origin)
+ url.searchParams.set('format', 'pdf')
+ if (params?.date_from) url.searchParams.set('date_from', params.date_from)
+ if (params?.date_to) url.searchParams.set('date_to', params.date_to)
+ return url.toString()
+ },
+}
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/settings.api.ts b/frontend/src/api/settings.api.ts
index f18f64b..e201e2e 100644
--- a/frontend/src/api/settings.api.ts
+++ b/frontend/src/api/settings.api.ts
@@ -2,13 +2,17 @@
* API client per le impostazioni del tenant.
*
* Endpoint:
- * GET /api/v1/settings → legge configurazione (admin)
- * PUT /api/v1/settings → aggiorna configurazione (admin)
+ * GET /api/v1/settings -> legge configurazione (admin)
+ * PUT /api/v1/settings -> aggiorna configurazione (admin)
+ * GET /api/v1/settings/indexing/stats -> statistiche indicizzazione
+ * GET /api/v1/settings/indexing/status -> stato job reindex
+ * POST /api/v1/settings/indexing/reindex -> avvia reindex
+ * DELETE /api/v1/settings/indexing/reindex -> cancella reindex
*/
import { apiClient } from './client'
-// ─── Tipi ──────────────────────────────────────────────────────────────────
+// ─── Tipi impostazioni generali ────────────────────────────────────────────
export type ArchivalMode = 'mock' | 'production'
@@ -37,7 +41,38 @@ export interface TenantSettingsUpdate {
archival_notes?: string
}
-// ─── Client ────────────────────────────────────────────────────────────────
+// ─── Tipi indicizzazione full-text ─────────────────────────────────────────
+
+export interface IndexingStats {
+ total_messages: number
+ indexed_messages: number
+ unindexed_messages: number
+ coverage_pct: number // 0-100
+
+ attachments_total: number
+ attachments_extracted: number
+ attachments_pct: number // 0-100
+}
+
+export type ReindexMode = 'full' | 'differential'
+export type JobStatus = 'idle' | 'running' | 'completed' | 'failed' | 'cancelled'
+
+export interface IndexingJobStatus {
+ status: JobStatus
+ mode: ReindexMode | null
+ total: number
+ processed: number
+ progress_pct: number
+
+ started_at: string | null // ISO datetime
+ finished_at: string | null // ISO datetime
+ started_by: string | null // email
+ elapsed_seconds: number | null
+ is_stale: boolean // running da piu' di 2 ore
+ error: string | null
+}
+
+// ─── Client impostazioni generali ──────────────────────────────────────────
export const settingsApi = {
/**
@@ -57,4 +92,97 @@ export const settingsApi = {
const { data } = await apiClient.put('/settings', payload)
return data
},
+
+ // ── Indicizzazione full-text ──────────────────────────────────────────────
+
+ /**
+ * Restituisce le statistiche di copertura dell'indicizzazione.
+ * @param tenantId - (solo super_admin) UUID del tenant target
+ */
+ getIndexingStats: async (tenantId?: string): Promise => {
+ const params = tenantId ? { tenant_id: tenantId } : undefined
+ const { data } = await apiClient.get('/settings/indexing/stats', { params })
+ return data
+ },
+
+ /**
+ * Restituisce lo stato del job di reindex in corso (o idle se nessuno).
+ * @param tenantId - (solo super_admin) UUID del tenant target
+ */
+ getIndexingStatus: async (tenantId?: string): Promise => {
+ const params = tenantId ? { tenant_id: tenantId } : undefined
+ const { data } = await apiClient.get('/settings/indexing/status', { params })
+ return data
+ },
+
+ /**
+ * Avvia un job di reindex in background.
+ * @param mode - 'differential' (solo NULL) o 'full' (tutti i messaggi)
+ * @param tenantId - (solo super_admin) UUID del tenant target
+ */
+ startReindex: async (mode: ReindexMode, tenantId?: string): Promise => {
+ const params = tenantId ? { tenant_id: tenantId } : undefined
+ const { data } = await apiClient.post(
+ '/settings/indexing/reindex',
+ { mode },
+ { params }
+ )
+ return data
+ },
+
+ /**
+ * Cancella il job di reindex in corso.
+ * @param tenantId - (solo super_admin) UUID del tenant target
+ */
+ cancelReindex: async (tenantId?: string): Promise => {
+ const params = tenantId ? { tenant_id: tenantId } : undefined
+ const { data } = await apiClient.delete(
+ '/settings/indexing/reindex',
+ { params }
+ )
+ return data
+ },
+
+ // ── Scansione allegati ────────────────────────────────────────────────────
+
+ /**
+ * Restituisce lo stato del job di scansione allegati (idle se nessuno).
+ * @param tenantId - (solo super_admin) UUID del tenant target
+ */
+ getRescanStatus: async (tenantId?: string): Promise => {
+ const params = tenantId ? { tenant_id: tenantId } : undefined
+ const { data } = await apiClient.get(
+ '/settings/indexing/rescan-status',
+ { params }
+ )
+ return data
+ },
+
+ /**
+ * Avvia un job di scansione allegati in background.
+ * @param force - false: solo allegati senza testo estratto; true: tutti
+ * @param tenantId - (solo super_admin) UUID del tenant target
+ */
+ startRescan: async (force: boolean = false, tenantId?: string): Promise => {
+ const params = tenantId ? { tenant_id: tenantId } : undefined
+ const { data } = await apiClient.post(
+ '/settings/indexing/rescan',
+ { force },
+ { params }
+ )
+ return data
+ },
+
+ /**
+ * Cancella il job di scansione allegati in corso.
+ * @param tenantId - (solo super_admin) UUID del tenant target
+ */
+ cancelRescan: async (tenantId?: string): Promise => {
+ const params = tenantId ? { tenant_id: tenantId } : undefined
+ const { data } = await apiClient.delete(
+ '/settings/indexing/rescan',
+ { params }
+ )
+ return 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 7c11e62..5f1c37d 100644
--- a/frontend/src/components/Layout/Sidebar.tsx
+++ b/frontend/src/components/Layout/Sidebar.tsx
@@ -49,6 +49,15 @@ import {
Star,
Archive,
Building2,
+ Trash2,
+ Search,
+ BarChart2,
+ ClipboardList,
+ ShieldCheck,
+ FileText,
+ Settings2,
+ BookUser,
+ Calendar,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
@@ -72,7 +81,7 @@ export function Sidebar() {
/** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */
const [collapsedVboxes, setCollapsedVboxes] = useState>(new Set())
- const { user, isAdmin, isSuperAdmin, logout } = useAuth()
+ const { user, isAdmin, isSuperAdmin, isSupervisor, logout } = useAuth()
const unreadCount = useInboxStore((s) => s.unreadCount)
// Le caselle PEC vengono caricate qui e condivise via React Query cache
@@ -83,6 +92,14 @@ export function Sidebar() {
})
const mailboxes = mailboxesData?.items ?? []
+ // Conteggio messaggi non letti per singola casella
+ const { data: unreadCounts = {} } = useQuery({
+ queryKey: ['mailboxes', 'unread-counts'],
+ queryFn: () => mailboxesApi.getUnreadCounts(),
+ staleTime: 60 * 1000,
+ refetchInterval: 120 * 1000,
+ })
+
// Virtual Box assegnate all'utente corrente
const { data: myVboxes = [] } = useQuery({
queryKey: ['virtual-boxes', 'my'],
@@ -266,6 +283,72 @@ export function Sidebar() {
{!collapsed && Archiviati }
+
+ {/* Cestino globale */}
+
+ 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 ? 'Cestino (tutte le caselle)' : undefined}
+ >
+
+ {!collapsed && Cestino }
+
+
+ {/* ── Sezione Conservazione (admin e supervisor) ── */}
+ {(isAdmin || isSupervisor) && (
+ <>
+ {!collapsed && (
+
+ )}
+
+ cn(
+ 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
+ isActive
+ ? 'bg-teal-700 text-white'
+ : 'text-teal-300 hover:bg-teal-900/40 hover:text-white',
+ collapsed && 'justify-center px-2',
+ )
+ }
+ title={collapsed ? 'Da Conservare' : undefined}
+ >
+
+ {!collapsed && Da Conservare }
+
+
+ cn(
+ 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
+ isActive
+ ? 'bg-teal-700 text-white'
+ : 'text-teal-300 hover:bg-teal-900/40 hover:text-white',
+ collapsed && 'justify-center px-2',
+ )
+ }
+ title={collapsed ? 'Storico Conservazione' : undefined}
+ >
+
+ {!collapsed && Storico }
+
+ >
+ )}
@@ -290,6 +373,7 @@ export function Sidebar() {
collapsed={collapsed}
isExpanded={isMailboxExpanded(mailbox.id)}
onToggle={() => toggleMailbox(mailbox.id)}
+ unreadCount={unreadCounts[mailbox.id] ?? 0}
/>
))}
@@ -323,6 +407,77 @@ export function Sidebar() {
)}
+ {/* ── 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 }
+
+
+ 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 ? 'Ricerca avanzata' : undefined}
+ >
+
+ {!collapsed && Ricerca }
+
+
+ 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 ? 'Dashboard / Report' : undefined}
+ >
+
+ {!collapsed && Dashboard }
+
+
+
+
{/* ── Nuova PEC ── */}
@@ -346,6 +501,40 @@ export function Sidebar() {
+ {/* ── Sezione Supervisione – visibile solo ai supervisor ── */}
+ {isSupervisor && (
+
+ {!collapsed && (
+ <>
+
+
+ Supervisione
+
+ >
+ )}
+ {collapsed &&
}
+
+
+
+ cn(
+ 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
+ isActive
+ ? 'bg-cyan-700 text-white'
+ : 'text-cyan-300 hover:bg-cyan-900/40 hover:text-white',
+ collapsed && 'justify-center px-2',
+ )
+ }
+ title={collapsed ? 'Caselle PEC' : undefined}
+ >
+
+ {!collapsed && Caselle PEC }
+
+
+
+ )}
+
{/* ── Sezione Amministrazione ── */}
{isAdmin && (
@@ -365,7 +554,10 @@ 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) => (
void
+ unreadCount?: number
}
/** Colore del pallino di stato casella */
@@ -493,10 +686,12 @@ function statusDot(status: MailboxResponse['status']): string {
}
}
-function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNavItemProps) {
+function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle, unreadCount = 0 }: MailboxNavItemProps) {
const displayName = mailbox.display_name || mailbox.email_address
const initial = displayName[0]?.toUpperCase() ?? '?'
const dotClass = statusDot(mailbox.status)
+ const { isAdmin, isSupervisor } = useAuth()
+ const canSeeConservation = isAdmin || isSupervisor
/* ── Modalità compressa: solo avatar/iniziale → link diretto all'inbox ── */
if (collapsed) {
@@ -511,7 +706,7 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
)
}
- title={displayName}
+ title={`${displayName}${unreadCount > 0 ? ` (${unreadCount} non letti)` : ''}`}
>
@@ -523,6 +718,11 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
dotClass,
)}
/>
+ {unreadCount > 0 && (
+
+ {unreadCount > 9 ? '9+' : unreadCount}
+
+ )}
)
@@ -554,6 +754,13 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
{displayName}
+ {/* Badge non letti per casella */}
+ {unreadCount > 0 && (
+
+ {unreadCount > 99 ? '99+' : unreadCount}
+
+ )}
+
{/* Chevron espandi/comprimi */}
Archiviati
+
+
+ cn(
+ 'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
+ isActive
+ ? 'bg-blue-600 text-white'
+ : 'text-gray-400 hover:bg-gray-700 hover:text-white',
+ )
+ }
+ >
+
+ Cestino
+
+
+ {/* Conservazione (solo admin/supervisor) */}
+ {canSeeConservation && (
+ <>
+
+ cn(
+ 'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
+ isActive
+ ? 'bg-teal-700 text-white'
+ : 'text-teal-400 hover:bg-teal-900/40 hover:text-white',
+ )
+ }
+ >
+
+ Da Conservare
+
+
+ cn(
+ 'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
+ isActive
+ ? 'bg-teal-700 text-white'
+ : 'text-teal-400 hover:bg-teal-900/40 hover:text-white',
+ )
+ }
+ >
+
+ Storico
+
+ >
+ )}
)}
diff --git a/frontend/src/components/ReceiptTree/ReceiptTree.tsx b/frontend/src/components/ReceiptTree/ReceiptTree.tsx
index 44f7e3a..601b89b 100644
--- a/frontend/src/components/ReceiptTree/ReceiptTree.tsx
+++ b/frontend/src/components/ReceiptTree/ReceiptTree.tsx
@@ -1,5 +1,6 @@
-import { ChevronDown, ChevronRight, Mail } from 'lucide-react'
+import { ChevronDown, ChevronRight, Mail, ExternalLink } from 'lucide-react'
import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
import { PecTypeBadge, PecStateBadge } from '@/components/PecBadge/PecBadge'
import { formatDate } from '@/lib/utils'
import type { MessageResponse } from '@/types/api.types'
@@ -12,9 +13,11 @@ interface ReceiptTreeProps {
/**
* Visualizza la gerarchia delle ricevute PEC collegate a un messaggio.
* Mostra in ordine cronologico: accettazione → consegna (o anomalia).
+ * Le ricevute sono cliccabili e navigano al dettaglio del messaggio ricevuta.
*/
export function ReceiptTree({ message, receipts }: ReceiptTreeProps) {
const [expanded, setExpanded] = useState(true)
+ const navigate = useNavigate()
if (receipts.length === 0) {
if (message.direction === 'outbound') {
@@ -66,6 +69,7 @@ export function ReceiptTree({ message, receipts }: ReceiptTreeProps) {
date={receipt.received_at || receipt.created_at}
type={receipt.pec_type}
messageId={receipt.id}
+ onClick={() => navigate(`/messages/${receipt.id}`)}
/>
))}
@@ -81,25 +85,41 @@ interface ReceiptNodeProps {
type?: MessageResponse['pec_type']
messageId?: string
isRoot?: boolean
+ onClick?: () => void
}
-function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
- return (
+function ReceiptNode({ label, date, state, type, isRoot, onClick }: ReceiptNodeProps) {
+ const isClickable = !!onClick
+
+ const content = (
{/* Indicatore timeline */}
-
{label}
+
+ {label}
+
{state && !isRoot &&
}
{type && type !== 'posta_certificata' &&
}
+ {isClickable && (
+
+ )}
{date && (
{formatDate(date)}
@@ -107,4 +127,19 @@ function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
)
+
+ if (isClickable) {
+ return (
+
+ {content}
+
+ )
+ }
+
+ return {content}
}
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
index 621fc32..d658db7 100644
--- a/frontend/src/hooks/useAuth.ts
+++ b/frontend/src/hooks/useAuth.ts
@@ -2,6 +2,14 @@ import { useAuthStore } from '@/store/auth.store'
/**
* Hook helper per accedere all'utente corrente e ai permessi.
+ *
+ * Gerarchia ruoli:
+ * super_admin > admin > supervisor > operator > readonly
+ *
+ * Supervisor:
+ * - Lettura implicita su tutte le caselle del tenant
+ * - Invio solo se ha permesso esplicito can_send sulla casella
+ * - Non puo' gestire caselle, utenti, permessi o impostazioni
*/
export function useAuth() {
const user = useAuthStore((s) => s.user)
@@ -11,6 +19,8 @@ export function useAuth() {
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'
const isSuperAdmin = user?.role === 'super_admin'
+ const isSupervisor = user?.role === 'supervisor'
+ const isSupervisorOrAdmin = isAdmin || isSupervisor
const canSend = user?.role !== 'readonly'
const canManage = isAdmin
@@ -20,6 +30,8 @@ export function useAuth() {
isLoading,
isAdmin,
isSuperAdmin,
+ isSupervisor,
+ isSupervisorOrAdmin,
canSend,
canManage,
logout,
diff --git a/frontend/src/pages/AuditLog/AuditLogPage.tsx b/frontend/src/pages/AuditLog/AuditLogPage.tsx
new file mode 100644
index 0000000..38234e2
--- /dev/null
+++ b/frontend/src/pages/AuditLog/AuditLogPage.tsx
@@ -0,0 +1,338 @@
+/**
+ * Pagina Audit Log – visualizzazione eventi di sistema.
+ *
+ * Accessibile solo ad admin e super_admin.
+ * Mostra una tabella paginata con filtri per data, azione ed esito.
+ */
+
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { format } from 'date-fns'
+import { it } from 'date-fns/locale'
+import { ShieldCheck, AlertCircle, Search, RotateCcw } from 'lucide-react'
+import { auditLogApi } from '@/api/audit_log.api'
+import type { AuditLogParams } from '@/api/audit_log.api'
+import { cn } from '@/lib/utils'
+
+// ─── Badge esito ──────────────────────────────────────────────────────────────
+
+function OutcomeBadge({ outcome }: { outcome: string }) {
+ const isSuccess = outcome === 'success'
+ return (
+
+ {isSuccess ? (
+
+ ) : (
+
+ )}
+ {isSuccess ? 'Successo' : 'Fallito'}
+
+ )
+}
+
+// ─── Etichetta azione leggibile ───────────────────────────────────────────────
+
+function actionLabel(action: string): string {
+ const map: Record = {
+ 'auth.login': 'Login',
+ 'auth.password_changed': 'Cambio password',
+ 'user.created': 'Utente creato',
+ 'user.updated': 'Utente modificato',
+ 'user.deleted': 'Utente eliminato',
+ 'mailbox.created': 'Casella creata',
+ 'mailbox.updated': 'Casella modificata',
+ 'mailbox.deleted': 'Casella eliminata',
+ 'message.sent': 'PEC inviata',
+ }
+ return map[action] ?? action
+}
+
+// ─── Componente principale ────────────────────────────────────────────────────
+
+export function AuditLogPage() {
+ const [page, setPage] = useState(1)
+ const PAGE_SIZE = 25
+
+ // Filtri
+ const [filterAction, setFilterAction] = useState('')
+ const [filterOutcome, setFilterOutcome] = useState<'' | 'success' | 'failure'>('')
+ const [filterDateFrom, setFilterDateFrom] = useState('')
+ const [filterDateTo, setFilterDateTo] = useState('')
+
+ // Parametri query attivi (applicati al click su "Cerca")
+ const [activeParams, setActiveParams] = useState({})
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['audit-log', page, activeParams],
+ queryFn: () =>
+ auditLogApi.list({
+ page,
+ page_size: PAGE_SIZE,
+ ...activeParams,
+ }),
+ staleTime: 30_000,
+ })
+
+ const handleSearch = () => {
+ setPage(1)
+ const params: AuditLogParams = {}
+ if (filterAction) params.action = filterAction
+ if (filterOutcome) params.outcome = filterOutcome
+ if (filterDateFrom) params.date_from = new Date(filterDateFrom).toISOString()
+ if (filterDateTo) params.date_to = new Date(filterDateTo + 'T23:59:59').toISOString()
+ setActiveParams(params)
+ }
+
+ const handleReset = () => {
+ setFilterAction('')
+ setFilterOutcome('')
+ setFilterDateFrom('')
+ setFilterDateTo('')
+ setActiveParams({})
+ setPage(1)
+ }
+
+ const items = data?.items ?? []
+ const total = data?.total ?? 0
+ const pages = data?.pages ?? 1
+
+ return (
+
+ {/* Intestazione */}
+
+
Audit Log
+
+ Registro cronologico degli eventi di sistema e delle operazioni degli utenti.
+
+
+
+ {/* Filtri */}
+
+
+ {/* Azione */}
+
+ Azione
+ setFilterAction(e.target.value)}
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ Tutte le azioni
+ Login
+ Cambio password
+ Utente creato
+ Utente modificato
+ Utente eliminato
+ Casella creata
+ Casella modificata
+ Casella eliminata
+ PEC inviata
+
+
+
+ {/* Esito */}
+
+ Esito
+ setFilterOutcome(e.target.value as '' | 'success' | 'failure')}
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ Tutti
+ Successo
+ Fallito
+
+
+
+ {/* Data da */}
+
+ Dal
+ setFilterDateFrom(e.target.value)}
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+ {/* Data a */}
+
+ Al
+ setFilterDateTo(e.target.value)}
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+ {/* Bottoni */}
+
+
+
+ Cerca
+
+
+
+ Reimposta
+
+
+
+
+ {/* Tabella */}
+
+ {/* Header tabella con count */}
+
+
+ {isLoading ? 'Caricamento...' : `${total.toLocaleString('it-IT')} eventi trovati`}
+
+ {total > 0 && (
+
+ Pagina {page} di {pages}
+
+ )}
+
+
+ {/* Stato errore */}
+ {isError && (
+
+
+
Errore nel caricamento dei dati.
+
+ )}
+
+ {/* Stato vuoto */}
+ {!isLoading && !isError && items.length === 0 && (
+
+
+
Nessun evento trovato.
+
+ )}
+
+ {/* Dati */}
+ {items.length > 0 && (
+
+
+
+
+
+ Data / Ora
+
+
+ Azione
+
+
+ Esito
+
+
+ Risorsa
+
+
+ IP
+
+
+ Utente
+
+
+
+
+ {items.map((entry) => (
+
+ {/* Data/ora */}
+
+ {format(new Date(entry.occurred_at), 'dd/MM/yyyy HH:mm:ss', { locale: it })}
+
+
+ {/* Azione */}
+
+
+ {actionLabel(entry.action)}
+
+
+ ({entry.action})
+
+
+
+ {/* Esito */}
+
+
+
+
+ {/* Risorsa */}
+
+ {entry.resource_type ? (
+
+ {entry.resource_type}
+ {entry.resource_id && (
+
+ {entry.resource_id.split('-')[0]}...
+
+ )}
+
+ ) : (
+ —
+ )}
+
+
+ {/* IP */}
+
+ {entry.ip_address ?? — }
+
+
+ {/* Utente (UUID abbreviato) */}
+
+ {entry.user_id ? (
+
+ {entry.user_id.split('-')[0]}...
+
+ ) : (
+ —
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Paginazione */}
+ {pages > 1 && (
+
+ setPage((p) => p - 1)}
+ className="px-3 py-1.5 text-sm border border-gray-300 rounded-md disabled:opacity-40 hover:bg-gray-50 transition-colors"
+ >
+ Precedente
+
+
+ {page} / {pages}
+
+ = pages}
+ onClick={() => setPage((p) => p + 1)}
+ className="px-3 py-1.5 text-sm border border-gray-300 rounded-md disabled:opacity-40 hover:bg-gray-50 transition-colors"
+ >
+ Successiva
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/Compose/ComposePage.tsx b/frontend/src/pages/Compose/ComposePage.tsx
index 53dbb1f..a08faa7 100644
--- a/frontend/src/pages/Compose/ComposePage.tsx
+++ b/frontend/src/pages/Compose/ComposePage.tsx
@@ -50,24 +50,46 @@ export function ComposePage() {
const navigate = useNavigate()
const location = useLocation()
const replyTo = location.state?.replyTo as MessageResponse | undefined
+ const forwardOf = location.state?.forwardOf as MessageResponse | undefined
const [showCc, setShowCc] = useState(false)
const fileInputRef = useRef(null)
// Corpo HTML (gestito da TipTap)
const [bodyHtml, setBodyHtml] = useState(() => {
- if (!replyTo) return ''
- const date = new Date(
- replyTo.received_at || replyTo.created_at
- ).toLocaleDateString('it-IT')
- return [
- '
',
- '
',
- ' ',
- `In risposta al messaggio del ${date}
`,
- `Da: ${replyTo.from_address || ''}
`,
- `A: ${replyTo.to_addresses?.join(', ') || ''}
`,
- `Oggetto: ${replyTo.subject || ''}
`,
- ].join('')
+ if (replyTo) {
+ const date = new Date(
+ replyTo.received_at || replyTo.created_at
+ ).toLocaleDateString('it-IT')
+ return [
+ '
',
+ '
',
+ ' ',
+ `In risposta al messaggio del ${date}
`,
+ `Da: ${replyTo.from_address || ''}
`,
+ `A: ${replyTo.to_addresses?.join(', ') || ''}
`,
+ `Oggetto: ${replyTo.subject || ''}
`,
+ ].join('')
+ }
+ if (forwardOf) {
+ const date = new Date(
+ forwardOf.received_at || forwardOf.sent_at || forwardOf.created_at
+ ).toLocaleDateString('it-IT')
+ const bodyContent = forwardOf.body_text
+ ? `${forwardOf.body_text} `
+ : ''
+ return [
+ '
',
+ '
',
+ ' ',
+ `Messaggio inoltrato
`,
+ `Da: ${forwardOf.from_address || ''}
`,
+ `A: ${forwardOf.to_addresses?.join(', ') || ''}
`,
+ `Data: ${date}
`,
+ `Oggetto: ${forwardOf.subject || ''}
`,
+ bodyContent,
+ ].join('')
+ }
+ return ''
})
// Allegati
@@ -80,12 +102,16 @@ export function ComposePage() {
formState: { errors },
} = useForm({
defaultValues: {
- mailbox_id: replyTo?.mailbox_id || '',
+ mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '',
to_addresses: replyTo
? [{ value: replyTo.from_address || '' }]
: [{ value: '' }],
cc_addresses: [],
- subject: replyTo ? `Re: ${replyTo.subject || ''}` : '',
+ subject: replyTo
+ ? `Re: ${replyTo.subject || ''}`
+ : forwardOf
+ ? `Fwd: ${forwardOf.subject || ''}`
+ : '',
},
})
@@ -214,13 +240,18 @@ export function ComposePage() {
- {replyTo ? 'Rispondi a PEC' : 'Nuova PEC'}
+ {replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC'}
{replyTo && (
In risposta a: {replyTo.subject}
)}
+ {forwardOf && (
+
+ Inoltro di: {forwardOf.subject}
+
+ )}
@@ -239,6 +270,17 @@ export function ComposePage() {
+ {/* Avviso allegati messaggio inoltrato */}
+ {forwardOf && forwardOf.has_attachments && (
+
+
+
+ Il messaggio originale contiene allegati. Per includerli nell'inoltro,
+ scaricali dal messaggio originale e aggiungili qui manualmente.
+
+
+ )}
+
{/* Casella mittente */}
Casella mittente *
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
+
+
+
+
fileInputRef.current?.click()} isLoading={importMutation.isPending}>
+
+ Importa CSV
+
+
+
+
+ Nuovo contatto
+
+
+
+
+
+
+
+ setQ(e.target.value)}
+ className="pl-9"
+ />
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+
+
Nessun contatto trovato
+
+ Aggiungi contatti manualmente o importa un CSV con colonne: email, name, organization
+
+
+ ) : (
+
+
+
+
+ Email PEC
+ Nome
+ Organizzazione
+ Tipo
+ Aggiornato
+
+
+
+
+ {items.map((c) => (
+
+ {c.email}
+ {c.name ?? '-'}
+
+ {c.organization ? (
+
+
+ {c.organization}
+
+ ) : '-'}
+
+
+
+ {c.auto_saved ? 'Automatico' : 'Manuale'}
+
+
+ {formatDate(c.updated_at)}
+
+
+
toggleFavMutation.mutate({ id: c.id, fav: !c.is_favorite })}
+ title={c.is_favorite ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
+ className="p-1 rounded hover:bg-muted transition-colors"
+ >
+
+
+
openEdit(c)}
+ className="p-1 rounded hover:bg-muted transition-colors"
+ title="Modifica"
+ >
+
+
+
{
+ if (confirm(`Eliminare il contatto ${c.email}?`)) deleteMutation.mutate(c.id)
+ }}
+ className="p-1 rounded hover:bg-muted transition-colors"
+ title="Elimina"
+ >
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
!o && closeForm()}>
+
+
+ {editing ? 'Modifica contatto' : 'Nuovo contatto'}
+
+
+
+
+
+ Annulla
+
+ {editing ? 'Salva' : 'Aggiungi'}
+
+
+
+
+
+ )
+}
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
+
+
+
+
+ setIncludeOverdue(e.target.checked)}
+ className="rounded"
+ />
+ Includi scaduti
+
+ setDaysAhead(Number(e.target.value))}
+ className="text-sm border rounded-md px-3 py-1.5 bg-background"
+ >
+ Prossimi 7 giorni
+ Prossimi 30 giorni
+ Prossimi 90 giorni
+ Prossimo anno
+
+
+
+
+
+ {isLoading ? (
+
+ ) : total === 0 ? (
+
+
+
Nessuna scadenza trovata
+
+ Le scadenze si impostano dal dettaglio di ogni messaggio.
+
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/Inbox/InboxPage.tsx b/frontend/src/pages/Inbox/InboxPage.tsx
index 4a99c74..0b03a74 100644
--- a/frontend/src/pages/Inbox/InboxPage.tsx
+++ b/frontend/src/pages/Inbox/InboxPage.tsx
@@ -1,17 +1,18 @@
/**
- * InboxPage – visualizza la posta in arrivo, inviata, preferiti o archiviata.
+ * InboxPage – visualizza la posta in arrivo, inviata, preferiti, archiviata o cestino.
*
- * Può operare in quattro modalità (viewMode):
- * - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false)
- * - 'sent' → Posta Inviata (solo outbound, is_archived=false)
- * - 'starred' → Preferiti (tutte le direzioni, is_starred=true)
- * - 'archived' → Archiviati (tutte le direzioni, is_archived=true)
+ * Modalita' (viewMode):
+ * - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false, is_trashed=false)
+ * - 'sent' → Posta Inviata (solo outbound, is_archived=false, is_trashed=false)
+ * - 'starred' → Preferiti (is_starred=true, is_trashed=false)
+ * - 'archived' → Archiviati (is_archived=true, is_trashed=false)
+ * - 'trash' → Cestino (is_trashed=true)
*
- * Funzionalità:
+ * Funzionalita':
* - Selezione singola e multipla tramite checkbox
- * - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio, assegna tag)
- * - Pulsanti azione rapida su hover di ogni riga (stella, archivia)
- * - Badge tag colorati per ogni messaggio
+ * - Barra azioni bulk (stella, archivia, cestino, segna da leggere, tag)
+ * - Pulsanti azione rapida su hover di ogni riga
+ * - "Segna come da leggere" e "Sposta nel cestino" solo fuori dalle Virtual Box
*/
import { useEffect, useState, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
@@ -32,6 +33,14 @@ import {
Square,
X,
Tag,
+ Trash2,
+ RotateCcw,
+ MailX,
+ SlidersHorizontal,
+ ChevronDown,
+ ChevronUp,
+ ShieldCheck,
+ ShieldX,
} from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
@@ -40,6 +49,7 @@ import { Input } from '@/components/ui/Input'
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
import { TagBadgeList } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector'
+import { useAuth } from '@/hooks/useAuth'
import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
import { mailboxesApi } from '@/api/mailboxes.api'
@@ -51,10 +61,9 @@ import { getErrorMessage } from '@/api/client'
// ─── Props ────────────────────────────────────────────────────────────────────
-export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived'
+export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash' | 'conservation_pending' | 'conservation_archived'
interface InboxPageProps {
- /** Modalità vista */
viewMode: InboxViewMode
}
@@ -64,10 +73,15 @@ export function InboxPage({ viewMode }: InboxPageProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
- // mailboxId è presente solo nei percorsi /mailbox/:mailboxId/...
- // vboxId è presente solo nei percorsi /virtual-box/:vboxId/...
const { mailboxId, vboxId } = useParams<{ mailboxId?: string; vboxId?: string }>()
+ // true = stiamo navigando in una casella reale (non virtual box)
+ const isMailboxMode = !vboxId
+
+ // ── Ruolo utente per visibilita' pulsante Conservazione ─────────────────────
+ const { isAdmin, isSupervisor } = useAuth()
+ const canConserve = isAdmin || isSupervisor
+
// ── Stato filtri locale ──────────────────────────────────────────────────────
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
@@ -76,23 +90,35 @@ export function InboxPage({ viewMode }: InboxPageProps) {
const [page, setPage] = useState(1)
const PAGE_SIZE = 50
+ // ── Filtri avanzati ──────────────────────────────────────────────────────────
+ const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
+ const [dateFrom, setDateFrom] = useState('')
+ const [dateTo, setDateTo] = useState('')
+ const [pecTypeFilter, setPecTypeFilter] = useState('')
+ const [pecStateFilter, setPecStateFilter] = useState('')
+
+ const activeAdvancedFiltersCount = [dateFrom, dateTo, pecTypeFilter, pecStateFilter].filter(Boolean).length
+
// ── Stato selezione ─────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState>(new Set())
// ── Stato dialog tag bulk ────────────────────────────────────────────────────
const [showTagSelector, setShowTagSelector] = useState(false)
- // Resetta lo stato UI ogni volta che si cambia casella, virtual box o cartella
useEffect(() => {
setSearchInput('')
setDebouncedSearch('')
setIsReadFilter(undefined)
setIsStarredFilter(undefined)
+ setDateFrom('')
+ setDateTo('')
+ setPecTypeFilter('')
+ setPecStateFilter('')
+ setShowAdvancedFilters(false)
setPage(1)
setSelectedIds(new Set())
}, [mailboxId, vboxId, viewMode])
- // Debounce della ricerca
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchInput)
@@ -112,7 +138,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
? mailboxesData?.items.find((m) => m.id === mailboxId)
: undefined
- // ── Virtual Box corrente (per breadcrumb) ────────────────────────────────────
const { data: myVboxes = [] } = useQuery({
queryKey: ['virtual-boxes', 'my'],
queryFn: () => virtualBoxesApi.myVirtualBoxes(),
@@ -124,41 +149,69 @@ export function InboxPage({ viewMode }: InboxPageProps) {
? myVboxes.find((v) => v.id === vboxId)
: undefined
+ // ── Reset pagina per filtri avanzati ─────────────────────────────────────────
+ useEffect(() => {
+ setPage(1)
+ }, [dateFrom, dateTo, pecTypeFilter])
+
// ── Query messaggi ───────────────────────────────────────────────────────────
const queryFilters = (() => {
- const base = {
+ const advancedBase = {
vbox_id: vboxId,
mailbox_id: mailboxId,
search: debouncedSearch || undefined,
+ pec_type: pecTypeFilter || undefined,
+ state: pecStateFilter || undefined,
+ date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
+ date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined,
page,
page_size: PAGE_SIZE,
}
switch (viewMode) {
case 'inbox':
return {
- ...base,
+ ...advancedBase,
direction: 'inbound' as const,
is_read: isReadFilter,
is_starred: isStarredFilter,
is_archived: false,
+ is_trashed: false,
}
case 'sent':
return {
- ...base,
+ ...advancedBase,
direction: 'outbound' as const,
is_starred: isStarredFilter,
is_archived: false,
+ is_trashed: false,
}
case 'starred':
return {
- ...base,
+ ...advancedBase,
is_starred: true,
is_archived: false,
+ is_trashed: false,
}
case 'archived':
return {
- ...base,
+ ...advancedBase,
is_archived: true,
+ is_trashed: false,
+ }
+ case 'trash':
+ return {
+ ...advancedBase,
+ is_trashed: true,
+ }
+ case 'conservation_pending':
+ return {
+ ...advancedBase,
+ is_pending_conservation: true,
+ }
+ case 'conservation_archived':
+ return {
+ ...advancedBase,
+ is_conserved: true,
}
}
})()
@@ -178,7 +231,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
const total = messagesData?.total || 0
const totalPages = Math.ceil(total / PAGE_SIZE)
- // ── Invalida query messaggi dopo operazioni ──────────────────────────────────
const invalidateMessages = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['messages'] })
}, [queryClient])
@@ -200,6 +252,25 @@ export function InboxPage({ viewMode }: InboxPageProps) {
},
})
+ // ── Segna come DA leggere (unread) ───────────────────────────────────────────
+ const markUnreadMutation = useMutation({
+ mutationFn: (id: string) => messagesApi.markUnread(id),
+ onSuccess: (updatedMsg) => {
+ queryClient.setQueryData(
+ ['messages', queryFilters],
+ (old: { items: MessageResponse[]; total: number } | undefined) => {
+ if (!old) return old
+ return {
+ ...old,
+ items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)),
+ }
+ },
+ )
+ toast.success('Messaggio segnato come da leggere')
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ })
+
// ── Toggle stella singolo ────────────────────────────────────────────────────
const toggleStarMutation = useMutation({
mutationFn: ({ id, starred }: { id: string; starred: boolean }) =>
@@ -209,7 +280,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
['messages', queryFilters],
(old: { items: MessageResponse[]; total: number } | undefined) => {
if (!old) return old
- // In vista "starred" rimuoviamo il messaggio se è stato rimosso dai preferiti
if (viewMode === 'starred' && !updatedMsg.is_starred) {
return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 }
}
@@ -226,7 +296,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
mutationFn: ({ id, archived }: { id: string; archived: boolean }) =>
archived ? messagesApi.archive(id) : messagesApi.unarchive(id),
onSuccess: (updatedMsg, { archived }) => {
- // Rimuove il messaggio dalla lista corrente (ha cambiato "stanza")
queryClient.setQueryData(
['messages', queryFilters],
(old: { items: MessageResponse[]; total: number } | undefined) => {
@@ -240,6 +309,42 @@ export function InboxPage({ viewMode }: InboxPageProps) {
onError: (error) => toast.error(getErrorMessage(error)),
})
+ // ── Cestino/Ripristina singolo ────────────────────────────────────────────────
+ const trashMutation = useMutation({
+ mutationFn: ({ id, trashed }: { id: string; trashed: boolean }) =>
+ trashed ? messagesApi.trash(id) : messagesApi.untrash(id),
+ onSuccess: (updatedMsg, { trashed }) => {
+ queryClient.setQueryData(
+ ['messages', queryFilters],
+ (old: { items: MessageResponse[]; total: number } | undefined) => {
+ if (!old) return old
+ return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 }
+ },
+ )
+ toast.success(trashed ? 'Messaggio spostato nel cestino' : 'Messaggio ripristinato dal cestino')
+ invalidateMessages()
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ })
+
+ // ── Conservazione/Rimozione singolo ───────────────────────────────────────────
+ const conserveMutation = useMutation({
+ mutationFn: ({ id, conserve }: { id: string; conserve: boolean }) =>
+ conserve ? messagesApi.conserve(id) : messagesApi.unconserve(id),
+ onSuccess: (updatedMsg, { conserve }) => {
+ queryClient.setQueryData(
+ ['messages', queryFilters],
+ (old: { items: MessageResponse[]; total: number } | undefined) => {
+ if (!old) return old
+ return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 }
+ },
+ )
+ toast.success(conserve ? 'Messaggio inviato in Da Conservare' : 'Messaggio rimosso da Da Conservare')
+ invalidateMessages()
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ })
+
// ── Azioni bulk ──────────────────────────────────────────────────────────────
const bulkMutation = useMutation({
mutationFn: messagesApi.bulkUpdate,
@@ -247,10 +352,15 @@ export function InboxPage({ viewMode }: InboxPageProps) {
invalidateMessages()
setSelectedIds(new Set())
const n = result.updated
- if (payload.is_starred === true) toast.success(`${n} ${n === 1 ? 'messaggio aggiunto' : 'messaggi aggiunti'} ai preferiti`)
+ if (payload.is_read === false) toast.success(`${n} ${n === 1 ? 'messaggio segnato' : 'messaggi segnati'} come da leggere`)
+ else if (payload.is_starred === true) toast.success(`${n} ${n === 1 ? 'messaggio aggiunto' : 'messaggi aggiunti'} ai preferiti`)
else if (payload.is_starred === false) toast.success(`${n} ${n === 1 ? 'messaggio rimosso' : 'messaggi rimossi'} dai preferiti`)
else if (payload.is_archived === true) toast.success(`${n} ${n === 1 ? 'messaggio archiviato' : 'messaggi archiviati'}`)
else if (payload.is_archived === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato' : 'messaggi ripristinati'}`)
+ else if (payload.is_trashed === true) toast.success(`${n} ${n === 1 ? 'messaggio spostato nel cestino' : 'messaggi spostati nel cestino'}`)
+ else if (payload.is_trashed === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato dal cestino' : 'messaggi ripristinati dal cestino'}`)
+ else if (payload.is_pending_conservation === true) toast.success(`${n} ${n === 1 ? 'messaggio inviato' : 'messaggi inviati'} in Da Conservare`)
+ else if (payload.is_pending_conservation === false) toast.success(`${n} ${n === 1 ? 'messaggio rimosso' : 'messaggi rimossi'} da Da Conservare`)
},
onError: (error) => toast.error(getErrorMessage(error)),
})
@@ -280,6 +390,8 @@ export function InboxPage({ viewMode }: InboxPageProps) {
})
}
+ const handleBulkMarkUnread = () =>
+ bulkMutation.mutate({ ids: Array.from(selectedIds), is_read: false })
const handleBulkStar = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: true })
const handleBulkUnstar = () =>
@@ -288,6 +400,14 @@ export function InboxPage({ viewMode }: InboxPageProps) {
bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: true })
const handleBulkUnarchive = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: false })
+ const handleBulkTrash = () =>
+ bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: true })
+ const handleBulkUntrash = () =>
+ bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: false })
+ const handleBulkConserve = () =>
+ bulkMutation.mutate({ ids: Array.from(selectedIds), is_pending_conservation: true })
+ const handleBulkUnconserve = () =>
+ bulkMutation.mutate({ ids: Array.from(selectedIds), is_pending_conservation: false })
// ── Selezione ────────────────────────────────────────────────────────────────
const handleToggleSelect = (id: string, e: React.MouseEvent) => {
@@ -330,21 +450,20 @@ export function InboxPage({ viewMode }: InboxPageProps) {
// ── Label e icone folder ────────────────────────────────────────────────────
const isInbound = viewMode === 'inbox'
const folderLabel =
- viewMode === 'inbox'
- ? 'Posta in Arrivo'
- : viewMode === 'sent'
- ? 'Posta Inviata'
- : viewMode === 'starred'
- ? 'Preferiti'
- : 'Archiviati'
+ viewMode === 'inbox' ? 'Posta in Arrivo'
+ : viewMode === 'sent' ? 'Posta Inviata'
+ : viewMode === 'starred' ? 'Preferiti'
+ : viewMode === 'archived' ? 'Archiviati'
+ : viewMode === 'trash' ? 'Cestino'
+ : viewMode === 'conservation_pending' ? 'Da Conservare'
+ : 'Storico Conservazione'
const FolderIcon =
- viewMode === 'inbox'
- ? Inbox
- : viewMode === 'sent'
- ? Send
- : viewMode === 'starred'
- ? Star
- : Archive
+ viewMode === 'inbox' ? Inbox
+ : viewMode === 'sent' ? Send
+ : viewMode === 'starred' ? Star
+ : viewMode === 'archived' ? Archive
+ : viewMode === 'trash' ? Trash2
+ : ShieldCheck
const selectedCount = selectedIds.size
const allSelected = messages.length > 0 && selectedCount === messages.length
@@ -396,10 +515,12 @@ export function InboxPage({ viewMode }: InboxPageProps) {
Aggiorna
- navigate('/compose')}>
-
- Nuova PEC
-
+ {viewMode !== 'trash' && (
+ navigate('/compose')}>
+
+ Nuova PEC
+
+ )}
@@ -440,13 +561,107 @@ export function InboxPage({ viewMode }: InboxPageProps) {
Preferiti
)}
+
+ {/* Pulsante filtri avanzati */}
+ 0 && 'border-primary text-primary')}
+ onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
+ >
+
+ Filtri
+ {activeAdvancedFiltersCount > 0 && (
+
+ {activeAdvancedFiltersCount}
+
+ )}
+ {showAdvancedFilters ? : }
+
+
+ {/* ── Pannello filtri avanzati ── */}
+ {showAdvancedFilters && (
+
+ {/* Data da */}
+
+ Data da
+ setDateFrom(e.target.value)}
+ />
+
+
+ {/* Data a */}
+
+ Data a
+ setDateTo(e.target.value)}
+ />
+
+
+ {/* Tipo PEC */}
+
+ Tipo PEC
+ setPecTypeFilter(e.target.value)}
+ >
+ Tutti i tipi
+ Posta certificata
+ Accettazione
+ Avvenuta consegna
+ Mancata consegna
+ Non accettazione
+ Errore consegna
+
+
+
+ {/* Stato PEC */}
+
+ Stato PEC
+ setPecStateFilter(e.target.value)}
+ >
+ Tutti gli stati
+ Ricevuto
+ Inviato
+ Accettato
+ Consegnato
+ Anomalia
+ Fallito
+
+
+
+ {/* Reset filtri */}
+ {activeAdvancedFiltersCount > 0 && (
+
+ { setDateFrom(''); setDateTo(''); setPecTypeFilter(''); setPecStateFilter('') }}
+ >
+
+ Azzera filtri
+
+
+ )}
+
+ )}
- {/* ── Barra azioni bulk (appare quando ci sono selezioni) ── */}
+ {/* ── Barra azioni bulk ── */}
{someSelected && (
- {/* Contatore + deseleziona */}
- {/* Azioni: variano in base alla vista */}
- {viewMode !== 'starred' && (
+ {/* Segna come da leggere (solo in modalita' casella, non vbox) */}
+ {isMailboxMode && viewMode !== 'trash' && (
+
+
+ Segna da leggere
+
+ )}
+
+ {/* Stella */}
+ {viewMode !== 'starred' && viewMode !== 'trash' && (
)}
- {viewMode !== 'archived' && (
+ {viewMode !== 'archived' && viewMode !== 'trash' && (
)}
- {/* Assegna tag bulk */}
- setShowTagSelector(true)}
- >
-
- Tag
-
+ {/* Cestino / Ripristina cestino (solo modalita' casella) */}
+ {isMailboxMode && viewMode !== 'trash' && (
+
+
+ Cestino
+
+ )}
+
+ {isMailboxMode && viewMode === 'trash' && (
+
+
+ Ripristina dal cestino
+
+ )}
+
+ {/* Tag */}
+ {viewMode !== 'trash' && (
+ setShowTagSelector(true)}
+ >
+
+ Tag
+
+ )}
+
+ {/* Invia a Conservazione (solo admin/supervisor, non nelle viste conservazione) */}
+ {canConserve && viewMode !== 'conservation_pending' && viewMode !== 'conservation_archived' && viewMode !== 'trash' && (
+
+
+ Invia a Conservazione
+
+ )}
+
+ {/* Rimuovi da Da Conservare */}
+ {canConserve && viewMode === 'conservation_pending' && (
+
+
+ Rimuovi da Da Conservare
+
+ )}
)}
@@ -558,17 +844,18 @@ export function InboxPage({ viewMode }: InboxPageProps) {
{debouncedSearch || isReadFilter !== undefined || isStarredFilter !== undefined
? 'Prova a modificare i filtri di ricerca'
: viewMode === 'inbox'
- ? 'La posta in arrivo è vuota'
+ ? 'La posta in arrivo e vuota'
: viewMode === 'sent'
? 'Nessun messaggio inviato'
: viewMode === 'starred'
? 'Nessun messaggio nei preferiti'
- : 'Nessun messaggio archiviato'}
+ : viewMode === 'archived'
+ ? 'Nessun messaggio archiviato'
+ : 'Il cestino e vuoto'}
) : (
- {/* Riga "seleziona tutto" */}
{messages.length > 0 && (
handleToggleSelect(message.id, e)}
onClick={() => handleMessageClick(message)}
@@ -605,6 +894,18 @@ export function InboxPage({ viewMode }: InboxPageProps) {
e.stopPropagation()
archiveMutation.mutate({ id: message.id, archived: !message.is_archived })
}}
+ onMarkUnread={(e) => {
+ e.stopPropagation()
+ markUnreadMutation.mutate(message.id)
+ }}
+ onToggleTrash={(e) => {
+ e.stopPropagation()
+ trashMutation.mutate({ id: message.id, trashed: !message.is_trashed })
+ }}
+ onToggleConserve={(e) => {
+ e.stopPropagation()
+ conserveMutation.mutate({ id: message.id, conserve: !message.is_pending_conservation })
+ }}
mailboxName={
!mailboxId
? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address
@@ -663,23 +964,32 @@ export function InboxPage({ viewMode }: InboxPageProps) {
interface MessageRowProps {
message: MessageResponse
viewMode: InboxViewMode
+ isMailboxMode: boolean
isSelected: boolean
+ canConserve: boolean
onSelect: (e: React.MouseEvent) => void
onClick: () => void
onToggleStar: (e: React.MouseEvent) => void
onToggleArchive: (e: React.MouseEvent) => void
- /** Presente solo nella vista globale – mostra la casella di appartenenza */
+ onMarkUnread: (e: React.MouseEvent) => void
+ onToggleTrash: (e: React.MouseEvent) => void
+ onToggleConserve: (e: React.MouseEvent) => void
mailboxName?: string
}
function MessageRow({
message,
viewMode,
+ isMailboxMode,
isSelected,
+ canConserve,
onSelect,
onClick,
onToggleStar,
onToggleArchive,
+ onMarkUnread,
+ onToggleTrash,
+ onToggleConserve,
mailboxName,
}: MessageRowProps) {
const [hovered, setHovered] = useState(false)
@@ -770,7 +1080,6 @@ function MessageRow({
- {/* Tag badges */}
{message.labels && message.labels.length > 0 && (
e.stopPropagation()}>
@@ -784,9 +1093,9 @@ function MessageRow({
)}
- {/* ── Azioni rapide (visibili su hover) + indicatori fissi ── */}
+ {/* ── Azioni rapide (visibili su hover) ── */}
- {/* Pulsante stella (rapido, su hover o se stellata) */}
+ {/* Stella */}
- {/* Pulsante archivia/ripristina (rapido, su hover) */}
-
- {viewMode === 'archived' ? (
-
- ) : (
-
- )}
-
+ {/* Segna come da leggere (solo in modalita' casella, solo messaggi gia' letti) */}
+ {isMailboxMode && message.is_read && viewMode !== 'trash' && (
+
+
+
+ )}
+
+ {/* Archivia/Ripristina (non nel cestino) */}
+ {viewMode !== 'trash' && (
+
+ {viewMode === 'archived' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* Cestino / Ripristina dal cestino (solo in modalita' casella) */}
+ {isMailboxMode && (
+
+ {viewMode === 'trash' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* Invia a Conservazione / Rimuovi da Da Conservare */}
+ {canConserve && viewMode !== 'trash' && viewMode !== 'conservation_archived' && (
+
+ {viewMode === 'conservation_pending' ? (
+
+ ) : (
+
+ )}
+
+ )}
{/* Indicatore allegati */}
{message.has_attachments && (
diff --git a/frontend/src/pages/Mailboxes/MailboxesPage.tsx b/frontend/src/pages/Mailboxes/MailboxesPage.tsx
index 2eb04ed..955ff4a 100644
--- a/frontend/src/pages/Mailboxes/MailboxesPage.tsx
+++ b/frontend/src/pages/Mailboxes/MailboxesPage.tsx
@@ -27,6 +27,7 @@ import { mailboxesApi } from '@/api/mailboxes.api'
import { getErrorMessage } from '@/api/client'
import { formatDate, MAILBOX_STATUS_LABELS } from '@/lib/utils'
import { cn } from '@/lib/utils'
+import { useAuth } from '@/hooks/useAuth'
import type { MailboxCreateRequest, MailboxResponse } from '@/types/api.types'
const STATUS_COLORS = {
@@ -45,10 +46,12 @@ const STATUS_ICONS = {
export function MailboxesPage() {
const queryClient = useQueryClient()
+ const { isAdmin } = useAuth()
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [editingMailbox, setEditingMailbox] = useState
(null)
const [testingId, setTestingId] = useState(null)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
+ const [syncingId, setSyncingId] = useState(null)
const { data: mailboxesData, isLoading } = useQuery({
queryKey: ['mailboxes'],
@@ -78,6 +81,23 @@ export function MailboxesPage() {
}
}
+ const handleForceSync = async (mailbox: MailboxResponse) => {
+ setSyncingId(mailbox.id)
+ try {
+ const result = await mailboxesApi.forceSync(mailbox.id)
+ if (result.status === 'enqueued') {
+ toast.success(result.message)
+ queryClient.invalidateQueries({ queryKey: ['mailboxes'] })
+ } else {
+ toast.error(result.message)
+ }
+ } catch (e) {
+ toast.error(getErrorMessage(e))
+ } finally {
+ setSyncingId(null)
+ }
+ }
+
const handleDelete = async (mailbox: MailboxResponse) => {
if (!confirm(`Eliminare la casella ${mailbox.email_address}? L'operazione è irreversibile.`)) return
deleteMutation.mutate(mailbox.id)
@@ -94,10 +114,12 @@ export function MailboxesPage() {
Caselle PEC
({mailboxes.length})
-
setShowCreateDialog(true)}>
-
- Aggiungi casella
-
+ {isAdmin && (
+
setShowCreateDialog(true)}>
+
+ Aggiungi casella
+
+ )}
{/* Contenuto */}
@@ -111,12 +133,14 @@ export function MailboxesPage() {
Nessuna casella PEC configurata
- Aggiungi una casella per iniziare a gestire le PEC
+ {isAdmin ? 'Aggiungi una casella per iniziare a gestire le PEC' : 'Nessuna casella disponibile'}
- setShowCreateDialog(true)}>
-
- Aggiungi casella
-
+ {isAdmin && (
+ setShowCreateDialog(true)}>
+
+ Aggiungi casella
+
+ )}
) : (
@@ -168,38 +192,53 @@ export function MailboxesPage() {
-
- {/* Test connessione */}
-
handleTest(mailbox)}
- isLoading={testingId === mailbox.id}
- title="Testa connessione IMAP"
- >
-
- Test
-
+ {isAdmin && (
+
+ {/* Test connessione */}
+ handleTest(mailbox)}
+ isLoading={testingId === mailbox.id}
+ title="Testa connessione IMAP"
+ >
+
+ Test
+
- {/* Modifica */}
- setEditingMailbox(mailbox)}
- >
-
-
+ {/* Forza sincronizzazione */}
+ handleForceSync(mailbox)}
+ isLoading={syncingId === mailbox.id}
+ title="Forza sincronizzazione IMAP immediata"
+ disabled={mailbox.status === 'deleted'}
+ >
+
+ Sync
+
- {/* Elimina */}
- handleDelete(mailbox)}
- className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
- >
-
-
-
+ {/* Modifica */}
+
setEditingMailbox(mailbox)}
+ >
+
+
+
+ {/* Elimina */}
+
handleDelete(mailbox)}
+ className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
+ >
+
+
+
+ )}
)
})}
diff --git a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx
index c927521..170cc35 100644
--- a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx
+++ b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx
@@ -8,29 +8,158 @@ import {
ArchiveX,
Download,
Reply,
+ Forward,
Paperclip,
Mail,
Send,
Tag,
+ Trash2,
+ 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 (
+
!isCurrent && navigate(`/messages/${msg.id}`)}
+ className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors ${
+ isCurrent
+ ? 'bg-primary/10 border border-primary/30 cursor-default'
+ : 'hover:bg-muted/60 cursor-pointer'
+ }`}
+ >
+
+
+
+ {msg.subject || '(nessun oggetto)'}
+
+
+ {msg.direction === 'inbound' ? msg.from_address : `A: ${(msg.to_addresses || []).join(', ')}`}
+ {' · '}
+ {formatDate(msg.received_at || msg.sent_at || msg.created_at)}
+
+
+ {isCurrent && Questo }
+
+ )
+ })}
+
+
+ )
+}
+
+// ─── Attachment preview modal ─────────────────────────────────────────────────
+
+interface AttachmentPreviewProps {
+ messageId: string
+ attachmentId: string
+ filename: string
+ contentType: string
+ onClose: () => void
+}
+
+function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType, onClose }: AttachmentPreviewProps) {
+ const { data, isLoading } = useQuery({
+ queryKey: ['attachment-preview', messageId, attachmentId],
+ queryFn: () => messagesApi.getAttachmentPreviewUrl(messageId, attachmentId),
+ })
+
+ return (
+
+
+
+ {filename}
+
+
+
+
+
+ {isLoading && (
+
+ )}
+ {!isLoading && data && (
+ <>
+ {data.previewable && data.url ? (
+ <>
+ {contentType.startsWith('image/') ? (
+
+ ) : contentType === 'application/pdf' ? (
+
+ ) : null}
+ >
+ ) : (
+
+
+
Anteprima non disponibile per questo tipo di file.
+
+ )}
+ >
+ )}
+
+
+
+ )
+}
+
+// ─── Pagina principale ────────────────────────────────────────────────────────
export function MessageDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
- // Dialog tag
const [showTagSelector, setShowTagSelector] = useState(false)
+ const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
+ const [isPrinting, setIsPrinting] = useState(false)
+
+ // Feature 4: Deadline
+ const [showDeadlineForm, setShowDeadlineForm] = useState(false)
+ const [deadlineDate, setDeadlineDate] = useState('')
+ const [deadlineNote, setDeadlineNote] = useState('')
+
+ // Feature 7: Preview allegati
+ const [previewAtt, setPreviewAtt] = useState<{ id: string; filename: string; contentType: string } | null>(null)
// Carica messaggio
const {
@@ -62,7 +191,6 @@ export function MessageDetailPage() {
mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred),
onSuccess: (updated) => {
queryClient.setQueryData(['message', id], updated)
- // Invalida le query messaggi per aggiornare le viste Preferiti
queryClient.invalidateQueries({ queryKey: ['messages'] })
toast.success(updated.is_starred ? 'Aggiunto ai preferiti' : 'Rimosso dai preferiti')
},
@@ -80,15 +208,6 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)),
})
- // Download allegato autenticato
- const handleDownloadAttachment = async (att: { id: string; filename: string }) => {
- try {
- await messagesApi.downloadAttachment(message?.id ?? id!, att.id, att.filename)
- } catch (error) {
- toast.error(getErrorMessage(error))
- }
- }
-
// Ripristina dall'archivio
const unarchiveMutation = useMutation({
mutationFn: () => messagesApi.unarchive(id!),
@@ -100,17 +219,73 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)),
})
+ // Segna come da leggere
+ const markUnreadMutation = useMutation({
+ mutationFn: () => messagesApi.markUnread(id!),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(['message', id], updated)
+ queryClient.invalidateQueries({ queryKey: ['messages'] })
+ toast.success('Messaggio segnato come da leggere')
+ navigate(-1)
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ })
+
+ // Sposta nel cestino
+ const trashMutation = useMutation({
+ mutationFn: () => messagesApi.trash(id!),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(['message', id], updated)
+ queryClient.invalidateQueries({ queryKey: ['messages'] })
+ toast.success('Messaggio spostato nel cestino')
+ navigate(-1)
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ })
+
+ // Ripristina dal cestino
+ const untrashMutation = useMutation({
+ mutationFn: () => messagesApi.untrash(id!),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(['message', id], updated)
+ queryClient.invalidateQueries({ queryKey: ['messages'] })
+ toast.success('Messaggio ripristinato dal cestino')
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ })
+
+ // Download allegato autenticato
+ const handleDownloadAttachment = async (att: { id: string; filename: string }) => {
+ try {
+ await messagesApi.downloadAttachment(message?.id ?? id!, att.id, att.filename)
+ } catch (error) {
+ toast.error(getErrorMessage(error))
+ }
+ }
+
+ // Download pacchetto completo ZIP
+ const handleDownloadPackage = async () => {
+ if (!message) return
+ setIsDownloadingPackage(true)
+ try {
+ await messagesApi.downloadPackage(message.id, message.subject)
+ toast.success('Pacchetto scaricato')
+ } catch (error) {
+ toast.error(getErrorMessage(error))
+ } finally {
+ setIsDownloadingPackage(false)
+ }
+ }
+
// Imposta tag del messaggio
const setLabelsMutation = useMutation({
mutationFn: (labelIds: string[]) =>
labelsApi.setMessageLabels(id!, { label_ids: labelIds }),
onSuccess: (updatedLabels) => {
- // Aggiorna la cache del messaggio con i nuovi label
queryClient.setQueryData(['message', id], (old: typeof message) => {
if (!old) return old
return { ...old, labels: updatedLabels }
})
- // Invalida la lista messaggi per aggiornare i badge nella inbox
queryClient.invalidateQueries({ queryKey: ['messages'] })
setShowTagSelector(false)
toast.success('Tag aggiornati')
@@ -118,7 +293,7 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)),
})
- // Rimuove singolo tag (click su × nel badge)
+ // Rimuove singolo tag
const removeLabelMutation = useMutation({
mutationFn: (labelId: string) =>
labelsApi.removeMessageLabels(id!, { label_ids: [labelId] }),
@@ -197,8 +372,21 @@ export function MessageDetailPage() {
/>
- {/* Archivia (se non ancora archiviato) */}
- {!message.is_archived && (
+ {/* Segna come da leggere (solo se gia' letto) */}
+ {message.is_read && !message.is_trashed && (
+ markUnreadMutation.mutate()}
+ title="Segna come da leggere"
+ isLoading={markUnreadMutation.isPending}
+ >
+
+
+ )}
+
+ {/* Archivia (se non ancora archiviato e non nel cestino) */}
+ {!message.is_archived && !message.is_trashed && (
)}
- {/* Rispondi (solo per messaggi inbound PEC certificata) */}
- {message.direction === 'inbound' && message.pec_type === 'posta_certificata' && (
+ {/* Sposta nel cestino (se non gia' nel cestino) */}
+ {!message.is_trashed && (
+ trashMutation.mutate()}
+ title="Sposta nel cestino"
+ isLoading={trashMutation.isPending}
+ >
+
+
+ )}
+
+ {/* Ripristina dal cestino (se nel cestino) */}
+ {message.is_trashed && (
+ untrashMutation.mutate()}
+ title="Ripristina dal cestino"
+ isLoading={untrashMutation.isPending}
+ >
+
+ Ripristina
+
+ )}
+
+ {/* Rispondi (solo per messaggi inbound PEC certificata, non nel cestino) */}
+ {message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (
)}
+
+ {/* Inoltra (disponibile per qualsiasi messaggio PEC certificata, non nel cestino) */}
+ {message.pec_type === 'posta_certificata' && !message.is_trashed && (
+
+ navigate('/compose', {
+ state: { forwardOf: message },
+ })
+ }
+ >
+
+ Inoltra
+
+ )}
+
+ {/* Scarica pacchetto completo ZIP (sempre visibile) */}
+
+ {isDownloadingPackage ? (
+
+ ) : (
+
+ )}
+ Scarica
+
+
+ {/* Stampa (Feature 8) */}
+ {
+ if (!message) return
+ setIsPrinting(true)
+ try {
+ const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
+ const html = await response.data.text()
+ const w = window.open('', '_blank')
+ if (w) {
+ w.document.write(html)
+ w.document.close()
+ setTimeout(() => w.print(), 500)
+ }
+ } catch (e) {
+ toast.error('Errore apertura stampa')
+ } finally {
+ setIsPrinting(false)
+ }
+ }}
+ title="Stampa / Salva come PDF"
+ >
+
+ Stampa
+
+
+ {/* Scadenza (Feature 4) */}
+ {
+ const dl = (message as any).deadline_at
+ setDeadlineDate(dl ? dl.substring(0, 16) : '')
+ setDeadlineNote((message as any).deadline_note ?? '')
+ setShowDeadlineForm(true)
+ }}
+ title="Imposta scadenza"
+ className={(message as any).deadline_at ? 'border-amber-400 text-amber-600' : ''}
+ >
+
+ {(message as any).deadline_at ? 'Scadenza' : 'Scadenza'}
+
+ {/* Banner "Nel Cestino" */}
+ {message.is_trashed && (
+
+
+
+ Questo messaggio si trova nel cestino.
+
+
untrashMutation.mutate()}
+ isLoading={untrashMutation.isPending}
+ >
+
+ Ripristina
+
+
+ )}
+
{/* Banner "Archiviato" */}
- {message.is_archived && (
+ {message.is_archived && !message.is_trashed && (
@@ -379,25 +692,39 @@ export function MessageDetailPage() {
Allegati ({attachments.length})
- {attachments.map((att) => (
-
handleDownloadAttachment(att)}
- className="flex items-center gap-3 rounded-lg border bg-background p-3 hover:bg-muted/50 transition-colors group text-left w-full"
- >
-
-
+ {attachments.map((att) => {
+ const isPreviewable = att.content_type?.startsWith('image/') || att.content_type === 'application/pdf'
+ return (
+
+
handleDownloadAttachment(att)}
+ className="flex items-center gap-3 flex-1 min-w-0 text-left hover:opacity-80"
+ >
+
+
+
{att.filename}
+
+ {att.content_type || 'Tipo sconosciuto'} • {formatBytes(att.size_bytes)}
+
+
+
+
+ {isPreviewable && (
+
setPreviewAtt({ id: att.id, filename: att.filename, contentType: att.content_type || '' })}
+ className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-primary flex-shrink-0"
+ title="Anteprima"
+ >
+
+
+ )}
-
-
{att.filename}
-
- {att.content_type || 'Tipo sconosciuto'} • {formatBytes(att.size_bytes)}
-
-
-
-
- ))}
+ )
+ })}
)}
@@ -415,13 +742,48 @@ export function MessageDetailPage() {
)}
+ {/* Scadenza (Feature 4) */}
+ {(message as any).deadline_at && (
+
+
+
+
+
+ Scadenza: {formatDate((message as any).deadline_at)}
+
+ {(message as any).deadline_note && (
+ — {(message as any).deadline_note}
+ )}
+
+
{
+ setDeadlineDate('')
+ setDeadlineNote('')
+ deadlinesApi.setDeadline(message.id, { deadline_at: null }).then(() => {
+ queryClient.invalidateQueries({ queryKey: ['message', id] })
+ toast.success('Scadenza rimossa')
+ })
+ }}
+ className="text-xs text-amber-600 hover:text-amber-800"
+ >
+ Rimuovi
+
+
+
+ )}
+
+ {/* Thread (Feature 3) */}
+ {message.pec_type === 'posta_certificata' && (
+
+ )}
+
{/* Messaggio originale per ricevute */}
{message.direction === 'inbound' && message.pec_type !== 'posta_certificata' && (
- Questo è un messaggio automatico di tipo{' '}
+ Questo e un messaggio automatico di tipo{' '}
@@ -432,6 +794,63 @@ export function MessageDetailPage() {
+ {/* Modali */}
+
+ {/* Deadline form (Feature 4) */}
+ {showDeadlineForm && (
+
+
+
+
+ Imposta scadenza
+
+
+ Data e ora scadenza
+ setDeadlineDate(e.target.value)}
+ />
+
+
+ Nota (opzionale)
+ setDeadlineNote(e.target.value)}
+ placeholder="Es. Termine per impugnare, entro 30 giorni..."
+ />
+
+
+ setShowDeadlineForm(false)}>Annulla
+ {
+ await deadlinesApi.setDeadline(message.id, {
+ deadline_at: deadlineDate ? new Date(deadlineDate).toISOString() : null,
+ deadline_note: deadlineNote || null,
+ })
+ queryClient.invalidateQueries({ queryKey: ['message', id] })
+ toast.success(deadlineDate ? 'Scadenza impostata' : 'Scadenza rimossa')
+ setShowDeadlineForm(false)
+ }}
+ >
+ Salva
+
+
+
+
+ )}
+
+ {/* Attachment preview (Feature 7) */}
+ {previewAtt && (
+
setPreviewAtt(null)}
+ />
+ )}
+
{/* Dialog gestione tag */}
{showTagSelector && (
= {
+ delivered: '#22c55e',
+ accepted: '#3b82f6',
+ sent: '#8b5cf6',
+ anomaly: '#ef4444',
+ failed: '#dc2626',
+ queued: '#f59e0b',
+ draft: '#6b7280',
+ unknown: '#9ca3af',
+}
+
+const STATE_LABELS: Record = {
+ delivered: 'Consegnata',
+ accepted: 'Accettata',
+ sent: 'Inviata',
+ anomaly: 'Anomalia',
+ failed: 'Fallita',
+ queued: 'In coda',
+ draft: 'Bozza',
+ unknown: 'Sconosciuto',
+}
+
+const STATUS_BADGE: Record = {
+ active: 'bg-green-100 text-green-800',
+ paused: 'bg-yellow-100 text-yellow-800',
+ error: 'bg-red-100 text-red-800',
+ deleted: 'bg-gray-100 text-gray-600',
+}
+
+// ─── Componente KPI card ──────────────────────────────────────────────────────
+
+interface KpiCardProps {
+ title: string
+ value: string | number
+ subtitle?: string
+ icon: React.ReactNode
+ color: string
+ alert?: boolean
+}
+
+function KpiCard({ title, value, subtitle, icon, color, alert }: KpiCardProps) {
+ return (
+
+
+ {icon}
+
+
+
{title}
+
+ {value}
+
+ {subtitle && (
+
{subtitle}
+ )}
+
+
+ )
+}
+
+// ─── Tooltip grafico barre ────────────────────────────────────────────────────
+
+function CustomBarTooltip({ active, payload, label }: any) {
+ if (!active || !payload?.length) return null
+ return (
+
+
{label}
+ {payload.map((p: any) => (
+
+ {p.name}: {p.value}
+
+ ))}
+
+ )
+}
+
+// ─── Tooltip grafico torta ────────────────────────────────────────────────────
+
+function CustomPieTooltip({ active, payload }: any) {
+ if (!active || !payload?.length) return null
+ const item = payload[0]
+ return (
+
+
+ {STATE_LABELS[item.name] ?? item.name}
+
+
Totale: {item.value}
+
+ )
+}
+
+// ─── Pagina principale ────────────────────────────────────────────────────────
+
+export function ReportsPage() {
+ const [days, setDays] = useState(7)
+
+ const { data, isLoading, isError, refetch, isFetching } = useQuery({
+ queryKey: ['reports-summary', days],
+ queryFn: () => reportsApi.getSummary(days),
+ staleTime: 2 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ })
+
+ // ── Formatta le date per l'asse X del grafico ────────────────────────────
+ const chartData = (data?.daily_stats ?? []).map((s) => ({
+ giorno: format(parseISO(s.day), 'dd/MM', { locale: it }),
+ Ricevute: s.received,
+ Inviate: s.sent,
+ }))
+
+ const pieData = (data?.outbound_states ?? [])
+ .filter((s) => s.count > 0)
+ .map((s) => ({
+ name: s.state,
+ value: s.count,
+ fill: STATE_COLORS[s.state] ?? '#9ca3af',
+ }))
+
+ const kpi = data?.kpi
+ const generatedAt = data?.generated_at
+ ? format(parseISO(data.generated_at), "dd/MM/yyyy HH:mm", { locale: it })
+ : null
+
+ // ── URL export con token auth (il browser apre con i cookie di sessione) ──
+ const handleExport = (format: 'csv' | 'pdf') => {
+ const url = format === 'csv'
+ ? reportsApi.exportCsv()
+ : reportsApi.exportPdf()
+
+ // Apre il link in una nuova scheda; il token JWT viene inviato
+ // automaticamente grazie all'interceptor axios, ma per i download
+ // diretti usiamo fetch con il token dallo store.
+ const token = localStorage.getItem('access_token')
+ if (!token) {
+ window.open(url, '_blank')
+ return
+ }
+ fetch(url, { headers: { Authorization: `Bearer ${token}` } })
+ .then((res) => {
+ if (!res.ok) throw new Error('Errore download')
+ return res.blob()
+ })
+ .then((blob) => {
+ const ext = format === 'csv' ? 'csv' : 'pdf'
+ const ts = new Date().toISOString().slice(0, 16).replace('T', '_').replace(':', '')
+ const filename = `pechub_report_${ts}.${ext}`
+ const a = document.createElement('a')
+ a.href = URL.createObjectURL(blob)
+ a.download = filename
+ a.click()
+ URL.revokeObjectURL(a.href)
+ })
+ .catch(() => alert('Errore durante il download del report'))
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ return (
+
+ {/* ── Intestazione ── */}
+
+
+
+
+
Dashboard
+ {generatedAt && (
+
Aggiornato il {generatedAt}
+ )}
+
+
+
+
+ {/* Selettore periodo */}
+
+ {PERIOD_OPTIONS.map((opt) => (
+ setDays(opt.value)}
+ className={cn(
+ 'px-3 py-1.5 font-medium transition-colors',
+ days === opt.value
+ ? 'bg-blue-600 text-white'
+ : 'text-gray-600 hover:bg-gray-50',
+ )}
+ >
+ {opt.label}
+
+ ))}
+
+
+ {/* Aggiorna */}
+
refetch()}
+ disabled={isFetching}
+ className="p-2 rounded-lg border bg-white text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
+ title="Aggiorna dati"
+ >
+
+
+
+ {/* Export CSV */}
+
handleExport('csv')}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm text-gray-700 hover:bg-gray-50 transition-colors"
+ >
+
+ CSV
+
+
+ {/* Export PDF */}
+
handleExport('pdf')}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm text-gray-700 hover:bg-gray-50 transition-colors"
+ >
+
+ PDF
+
+
+
+
+ {/* ── Contenuto ── */}
+
+
+ {/* ── Loading / Errore ── */}
+ {isLoading && (
+
+
+ Caricamento dati...
+
+ )}
+
+ {isError && (
+
+ Errore nel caricamento dei dati. Riprova.
+
+ )}
+
+ {data && (
+ <>
+ {/* ── Riga KPI ── */}
+
+ }
+ color="bg-blue-50"
+ />
+ }
+ color="bg-indigo-50"
+ />
+ }
+ color="bg-orange-50"
+ alert={kpi!.anomalie_attive > 0}
+ />
+ }
+ color="bg-green-50"
+ />
+ }
+ color="bg-red-50"
+ alert={kpi!.caselle_in_errore > 0}
+ />
+ }
+ color="bg-purple-50"
+ />
+
+
+ {/* ── Grafici ── */}
+
+
+ {/* Grafico a barre (2/3) */}
+
+
+ Attivita PEC – ultimi {days} giorni
+
+ {chartData.length === 0 ? (
+
+ Nessun dato nel periodo selezionato
+
+ ) : (
+
+
+
+
+
+ } />
+
+
+
+
+
+ )}
+
+
+ {/* Grafico a torta (1/3) */}
+
+
+ Stato messaggi outbound
+
+ {pieData.length === 0 ? (
+
+ Nessun messaggio outbound
+
+ ) : (
+ <>
+
+
+
+ {pieData.map((entry, index) => (
+ |
+ ))}
+
+ } />
+
+
+ {/* Legenda manuale */}
+
+ {pieData.map((entry) => (
+
+
+
+
+ {STATE_LABELS[entry.name] ?? entry.name}
+
+
+
{entry.value}
+
+ ))}
+
+ >
+ )}
+
+
+
+ {/* ── Tabella caselle ── */}
+ {data.mailbox_stats.length > 0 && (
+
+
+
+ Dettaglio per casella
+
+
+
+
+
+
+ Casella
+ Stato
+ Ricevute
+ Inviate
+ Anomalie
+ Non letti
+ Ultima sync
+
+
+
+ {data.mailbox_stats.map((mb) => (
+
+
+
+ {mb.display_name || mb.email_address}
+
+ {mb.display_name && (
+ {mb.email_address}
+ )}
+
+
+
+ {mb.status}
+
+
+
+ {mb.received_total}
+
+
+ {mb.sent_total}
+
+
+ {mb.anomalie > 0 ? (
+ {mb.anomalie}
+ ) : (
+ 0
+ )}
+
+
+ {mb.non_letti > 0 ? (
+ {mb.non_letti}
+ ) : (
+ 0
+ )}
+
+
+ {mb.last_sync_at
+ ? format(parseISO(mb.last_sync_at), 'dd/MM HH:mm', { locale: it })
+ : '—'}
+
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Footer */}
+
+ Totale messaggi nel sistema: {kpi!.totale_messaggi.toLocaleString('it-IT')}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/RoutingRules/RoutingRulesPage.tsx b/frontend/src/pages/RoutingRules/RoutingRulesPage.tsx
new file mode 100644
index 0000000..fbd362c
--- /dev/null
+++ b/frontend/src/pages/RoutingRules/RoutingRulesPage.tsx
@@ -0,0 +1,394 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Plus, Trash2, CheckCircle, XCircle, Settings2, ChevronDown, ChevronUp } 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 { routingRulesApi, type RoutingRuleResponse, type RoutingRuleCreate, type ConditionField, type ConditionOperator, type ActionType } from '@/api/routing_rules.api'
+import { labelsApi } from '@/api/labels.api'
+import { getErrorMessage } from '@/api/client'
+import { formatDate } from '@/lib/utils'
+import { cn } from '@/lib/utils'
+
+const FIELD_LABELS: Record = {
+ from_address: 'Mittente',
+ to_address: 'Destinatario',
+ subject: 'Oggetto',
+ mailbox_id: 'ID Casella',
+ pec_type: 'Tipo PEC',
+}
+
+const OPERATOR_LABELS: Record = {
+ contains: 'contiene',
+ not_contains: 'non contiene',
+ equals: 'uguale a',
+ starts_with: 'inizia per',
+ ends_with: 'finisce per',
+ regex: 'regex',
+}
+
+const ACTION_LABELS: Record = {
+ apply_label: 'Applica etichetta',
+ assign_vbox: 'Assegna Virtual Box',
+ mark_read: 'Segna come letto',
+ mark_starred: 'Aggiungi ai preferiti',
+ notify_webhook: 'Notifica webhook',
+}
+
+interface Condition {
+ field: ConditionField
+ operator: ConditionOperator
+ value: string
+}
+
+interface Action {
+ action_type: ActionType
+ action_value: string
+}
+
+export function RoutingRulesPage() {
+ const queryClient = useQueryClient()
+ const [showForm, setShowForm] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [expandedRules, setExpandedRules] = useState>(new Set())
+
+ // Form state
+ const [formName, setFormName] = useState('')
+ const [formDescription, setFormDescription] = useState('')
+ const [formPriority, setFormPriority] = useState('100')
+ const [formStopProcessing, setFormStopProcessing] = useState(true)
+ const [formConditions, setFormConditions] = useState([
+ { field: 'from_address', operator: 'contains', value: '' }
+ ])
+ const [formActions, setFormActions] = useState([
+ { action_type: 'mark_read', action_value: '' }
+ ])
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['routing-rules'],
+ queryFn: () => routingRulesApi.list(),
+ })
+
+ const { data: labelsData } = useQuery({
+ queryKey: ['labels'],
+ queryFn: () => labelsApi.list(),
+ })
+ const labels = labelsData ?? []
+
+ const createMutation = useMutation({
+ mutationFn: (d: RoutingRuleCreate) => routingRulesApi.create(d),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
+ toast.success('Regola creata')
+ closeForm()
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: ({ id, data }: { id: string; data: RoutingRuleCreate }) =>
+ routingRulesApi.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
+ toast.success('Regola aggiornata')
+ closeForm()
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => routingRulesApi.delete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
+ toast.success('Regola eliminata')
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const toggleMutation = useMutation({
+ mutationFn: (id: string) => routingRulesApi.toggle(id),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-rules'] }),
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const openCreate = () => {
+ setEditing(null)
+ setFormName('')
+ setFormDescription('')
+ setFormPriority('100')
+ setFormStopProcessing(true)
+ setFormConditions([{ field: 'from_address', operator: 'contains', value: '' }])
+ setFormActions([{ action_type: 'mark_read', action_value: '' }])
+ setShowForm(true)
+ }
+
+ const openEdit = (r: RoutingRuleResponse) => {
+ setEditing(r)
+ setFormName(r.name)
+ setFormDescription(r.description ?? '')
+ setFormPriority(String(r.priority))
+ setFormStopProcessing(r.stop_processing)
+ setFormConditions(r.conditions.map(c => ({ field: c.field as ConditionField, operator: c.operator as ConditionOperator, value: c.value })))
+ setFormActions(r.actions.map(a => ({ action_type: a.action_type as ActionType, action_value: a.action_value ?? '' })))
+ setShowForm(true)
+ }
+
+ const closeForm = () => {
+ setShowForm(false)
+ setEditing(null)
+ }
+
+ const handleSubmit = () => {
+ if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
+ if (formConditions.some(c => !c.value.trim())) return toast.error('Tutte le condizioni devono avere un valore')
+
+ const payload: RoutingRuleCreate = {
+ name: formName.trim(),
+ description: formDescription.trim() || null,
+ priority: parseInt(formPriority) || 100,
+ stop_processing: formStopProcessing,
+ is_active: true,
+ conditions: formConditions.map(c => ({ field: c.field, operator: c.operator, value: c.value.trim() })),
+ actions: formActions.map(a => ({ action_type: a.action_type, action_value: a.action_value.trim() || null })),
+ }
+ if (editing) {
+ updateMutation.mutate({ id: editing.id, data: payload })
+ } else {
+ createMutation.mutate(payload)
+ }
+ }
+
+ const items = data?.items ?? []
+
+ const toggleExpand = (id: string) => {
+ setExpandedRules(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ return (
+
+
+
+
+
+ Regole di smistamento
+
+
+ Applica automaticamente etichette e azioni ai messaggi in arrivo
+
+
+
+
+ Nuova regola
+
+
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+
+
Nessuna regola configurata
+
Le regole vengono valutate in ordine di priorita' su ogni messaggio inbound.
+
+ ) : (
+ items.map((rule) => (
+
+
+
+
+ {rule.name}
+ P:{rule.priority}
+
+ {rule.is_active ? 'Attiva' : 'Inattiva'}
+
+ {rule.stop_processing && (
+ Stop
+ )}
+
+ {rule.description &&
{rule.description}
}
+
+ {rule.conditions.length} condizioni / {rule.actions.length} azioni
+
+
+
+ openEdit(rule)} title="Modifica">
+
+
+ toggleMutation.mutate(rule.id)}
+ title={rule.is_active ? 'Disattiva' : 'Attiva'}
+ >
+ {rule.is_active
+ ?
+ :
+ }
+
+ { if (confirm(`Eliminare la regola "${rule.name}"?`)) deleteMutation.mutate(rule.id) }}
+ title="Elimina"
+ >
+
+
+ toggleExpand(rule.id)}>
+ {expandedRules.has(rule.id) ? : }
+
+
+
+ {expandedRules.has(rule.id) && (
+
+
+
Condizioni (AND)
+ {rule.conditions.map((c, i) => (
+
+ {FIELD_LABELS[c.field as ConditionField] ?? c.field}
+ {OPERATOR_LABELS[c.operator as ConditionOperator] ?? c.operator}
+ {c.value}
+
+ ))}
+
+
+
Azioni
+ {rule.actions.map((a, i) => (
+
+ {ACTION_LABELS[a.action_type as ActionType] ?? a.action_type}
+ {a.action_value && {a.action_value} }
+
+ ))}
+
+
+ )}
+
+ ))
+ )}
+
+
+
!o && closeForm()}>
+
+
+ {editing ? 'Modifica regola' : 'Nuova regola di smistamento'}
+
+
+
+
+
+
+ setFormStopProcessing(e.target.checked)} className="rounded" />
+ Interrompi elaborazione dopo questa regola (stop processing)
+
+
+ {/* Condizioni */}
+
+
+
Condizioni (tutte devono essere soddisfatte - AND)
+
setFormConditions(prev => [...prev, { field: 'from_address', operator: 'contains', value: '' }])}>
+ Aggiungi
+
+
+ {formConditions.map((cond, i) => (
+
+ setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, field: e.target.value as ConditionField } : c))}
+ >
+ {(Object.entries(FIELD_LABELS) as [ConditionField, string][]).map(([v, l]) => {l} )}
+
+ setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, operator: e.target.value as ConditionOperator } : c))}
+ >
+ {(Object.entries(OPERATOR_LABELS) as [ConditionOperator, string][]).map(([v, l]) => {l} )}
+
+ setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
+ placeholder="Valore..."
+ />
+ setFormConditions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
+
+
+
+ ))}
+
+
+ {/* Azioni */}
+
+
+
Azioni da eseguire
+
setFormActions(prev => [...prev, { action_type: 'mark_read', action_value: '' }])}>
+ Aggiungi
+
+
+ {formActions.map((action, i) => (
+
+ setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_type: e.target.value as ActionType, action_value: '' } : a))}
+ >
+ {(Object.entries(ACTION_LABELS) as [ActionType, string][]).map(([v, l]) => {l} )}
+
+ {(action.action_type === 'apply_label') && (
+ setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
+ >
+ -- Seleziona etichetta --
+ {labels.map(l => {l.name} )}
+
+ )}
+ {(action.action_type === 'notify_webhook') && (
+ setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
+ placeholder="https://..."
+ />
+ )}
+ setFormActions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
+
+
+
+ ))}
+
+
+
+
+ Annulla
+
+ {editing ? 'Salva modifiche' : 'Crea regola'}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Search/SearchPage.tsx b/frontend/src/pages/Search/SearchPage.tsx
new file mode 100644
index 0000000..d6d639c
--- /dev/null
+++ b/frontend/src/pages/Search/SearchPage.tsx
@@ -0,0 +1,556 @@
+/**
+ * SearchPage – ricerca full-text avanzata tra tutti i messaggi PEC.
+ *
+ * Funzionalita':
+ * - Barra di ricerca full-text (usa FTS su search_vector PostgreSQL)
+ * - Pannello filtri avanzati collassabile:
+ * - Data da / Data a
+ * - Direzione (in arrivo / inviata)
+ * - Stato PEC
+ * - Tipo PEC
+ * - Casella specifica
+ * - Lista risultati ordinata per rilevanza
+ * - Paginazione
+ */
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+import {
+ Search,
+ SlidersHorizontal,
+ ChevronDown,
+ ChevronUp,
+ X,
+ Inbox,
+ Send,
+ MailOpen,
+ Mail,
+ ChevronLeft,
+ ChevronRight,
+} from 'lucide-react'
+import { useQuery } from '@tanstack/react-query'
+import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Input'
+import { PecStateBadge } from '@/components/PecBadge/PecBadge'
+import { TagBadgeList } from '@/components/TagManager/TagBadge'
+import { messagesApi } from '@/api/messages.api'
+import { mailboxesApi } from '@/api/mailboxes.api'
+import { formatRelative, truncate } from '@/lib/utils'
+import { cn } from '@/lib/utils'
+import type { MessageResponse } from '@/types/api.types'
+
+// ─── Costanti ─────────────────────────────────────────────────────────────────
+
+const PAGE_SIZE = 50
+
+const PEC_STATES = [
+ { value: '', label: 'Tutti gli stati' },
+ { value: 'received', label: 'Ricevuta' },
+ { value: 'sent', label: 'Inviata' },
+ { value: 'accepted', label: 'Accettata' },
+ { value: 'delivered', label: 'Consegnata' },
+ { value: 'anomaly', label: 'Anomalia' },
+ { value: 'failed', label: 'Fallita' },
+]
+
+const PEC_TYPES = [
+ { value: '', label: 'Tutti i tipi' },
+ { value: 'posta_certificata', label: 'Posta certificata' },
+ { value: 'accettazione', label: 'Accettazione' },
+ { value: 'avvenuta_consegna', label: 'Avvenuta consegna' },
+ { value: 'mancata_consegna', label: 'Mancata consegna' },
+ { value: 'non_accettazione', label: 'Non accettazione' },
+ { value: 'presa_in_carico', label: 'Presa in carico' },
+ { value: 'errore_consegna', label: 'Errore consegna' },
+ { value: 'preavviso_mancata_consegna', label: 'Preavviso mancata consegna' },
+ { value: 'rilevazione_virus', label: 'Rilevazione virus' },
+]
+
+const DIRECTIONS = [
+ { value: '', label: 'Tutte le direzioni' },
+ { value: 'inbound', label: 'In arrivo' },
+ { value: 'outbound', label: 'Inviata' },
+]
+
+// ─── Componente principale ────────────────────────────────────────────────────
+
+export function SearchPage() {
+ const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
+
+ // Legge i parametri dall'URL (permette di condividere/bookmark la ricerca)
+ const [searchInput, setSearchInput] = useState(searchParams.get('q') || '')
+ const [committedSearch, setCommittedSearch] = useState(searchParams.get('q') || '')
+ const [showFilters, setShowFilters] = useState(false)
+ const [page, setPage] = useState(1)
+
+ // Filtri avanzati
+ const [dateFrom, setDateFrom] = useState(searchParams.get('date_from') || '')
+ const [dateTo, setDateTo] = useState(searchParams.get('date_to') || '')
+ const [direction, setDirection] = useState(searchParams.get('direction') || '')
+ const [state, setState] = useState(searchParams.get('state') || '')
+ const [pecType, setPecType] = useState(searchParams.get('pec_type') || '')
+ const [mailboxId, setMailboxId] = useState(searchParams.get('mailbox_id') || '')
+
+ // Caselle disponibili per il filtro
+ const { data: mailboxesData } = useQuery({
+ queryKey: ['mailboxes'],
+ queryFn: () => mailboxesApi.list(),
+ staleTime: 5 * 60 * 1000,
+ })
+ const mailboxes = mailboxesData?.items ?? []
+
+ // Numero di filtri attivi
+ const activeFiltersCount = [dateFrom, dateTo, direction, state, pecType, mailboxId].filter(Boolean).length
+
+ // Sincronizza l'URL quando cambiano i filtri
+ useEffect(() => {
+ const params: Record = {}
+ if (committedSearch) params.q = committedSearch
+ if (dateFrom) params.date_from = dateFrom
+ if (dateTo) params.date_to = dateTo
+ if (direction) params.direction = direction
+ if (state) params.state = state
+ if (pecType) params.pec_type = pecType
+ if (mailboxId) params.mailbox_id = mailboxId
+ setSearchParams(params, { replace: true })
+ }, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId, setSearchParams])
+
+ // Reset pagina quando cambiano i filtri
+ useEffect(() => {
+ setPage(1)
+ }, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId])
+
+ // Costruisce i filtri per l'API
+ const filters = {
+ search: committedSearch || undefined,
+ date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
+ date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined,
+ direction: direction as 'inbound' | 'outbound' | undefined || undefined,
+ state: state || undefined,
+ pec_type: pecType || undefined,
+ mailbox_id: mailboxId || undefined,
+ is_archived: undefined as boolean | undefined,
+ is_trashed: false,
+ page,
+ page_size: PAGE_SIZE,
+ }
+
+ // Query messaggi
+ const {
+ data: messagesData,
+ isLoading,
+ isFetching,
+ } = useQuery({
+ queryKey: ['search', filters],
+ queryFn: () => messagesApi.list(filters),
+ enabled: !!(committedSearch || dateFrom || dateTo || direction || state || pecType || mailboxId),
+ })
+
+ const messages = messagesData?.items || []
+ const total = messagesData?.total || 0
+ const totalPages = Math.ceil(total / PAGE_SIZE)
+ const hasQuery = !!(committedSearch || activeFiltersCount > 0)
+
+ const handleSearch = useCallback(() => {
+ setCommittedSearch(searchInput.trim())
+ setPage(1)
+ }, [searchInput])
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') handleSearch()
+ }
+
+ const handleClearAll = () => {
+ setSearchInput('')
+ setCommittedSearch('')
+ setDateFrom('')
+ setDateTo('')
+ setDirection('')
+ setState('')
+ setPecType('')
+ setMailboxId('')
+ setPage(1)
+ }
+
+ const handleMessageClick = (message: MessageResponse) => {
+ navigate(`/messages/${message.id}`)
+ }
+
+ return (
+
+ {/* ── Header ricerca ── */}
+
+
+
+
+ Ricerca avanzata
+
+
+ Ricerca full-text su oggetto, corpo del messaggio e allegati PDF/DOCX
+
+
+
+ {/* Barra di ricerca principale */}
+
+
+
+ setSearchInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ />
+ {searchInput && (
+ { setSearchInput(''); setCommittedSearch('') }}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ >
+
+
+ )}
+
+
+
+ Cerca
+
+
0 && 'border-primary text-primary')}
+ onClick={() => setShowFilters(!showFilters)}
+ >
+
+ Filtri
+ {activeFiltersCount > 0 && (
+
+ {activeFiltersCount}
+
+ )}
+ {showFilters ? : }
+
+
+
+ {/* Pannello filtri avanzati */}
+ {showFilters && (
+
+
+ {/* Data da */}
+
+
+ Data da
+
+ setDateFrom(e.target.value)}
+ />
+
+
+ {/* Data a */}
+
+
+ Data a
+
+ setDateTo(e.target.value)}
+ />
+
+
+ {/* Direzione */}
+
+
+ Direzione
+
+ setDirection(e.target.value)}
+ >
+ {DIRECTIONS.map((d) => (
+ {d.label}
+ ))}
+
+
+
+ {/* Stato PEC */}
+
+
+ Stato PEC
+
+ setState(e.target.value)}
+ >
+ {PEC_STATES.map((s) => (
+ {s.label}
+ ))}
+
+
+
+ {/* Tipo PEC */}
+
+
+ Tipo PEC
+
+ setPecType(e.target.value)}
+ >
+ {PEC_TYPES.map((t) => (
+ {t.label}
+ ))}
+
+
+
+ {/* Casella */}
+ {mailboxes.length > 0 && (
+
+
+ Casella PEC
+
+ setMailboxId(e.target.value)}
+ >
+ Tutte le caselle
+ {mailboxes.map((m) => (
+
+ {m.display_name || m.email_address}
+
+ ))}
+
+
+ )}
+
+
+ {/* Pulsante reset filtri */}
+ {activeFiltersCount > 0 && (
+
+
+
+ Azzera tutti i filtri
+
+
+ )}
+
+ )}
+
+
+ {/* ── Risultati ── */}
+
+ {!hasQuery ? (
+
+
+
Inizia a cercare
+
+ Digita un termine nella barra di ricerca o usa i filtri avanzati per trovare i messaggi
+
+
+ ) : isLoading || isFetching ? (
+
+ ) : messages.length === 0 ? (
+
+
+
Nessun risultato
+
+ Prova a modificare i termini di ricerca o i filtri
+
+ {hasQuery && (
+
+
+ Azzera ricerca
+
+ )}
+
+ ) : (
+ <>
+ {/* Intestazione risultati */}
+
+
+ {total} {' '}
+ {total === 1 ? 'risultato trovato' : 'risultati trovati'}
+ {committedSearch && (
+ <> per "{committedSearch}" >
+ )}
+
+ {(isFetching) && (
+
+ )}
+
+
+ {/* Lista risultati */}
+
+ {messages.map((message) => (
+ m.id === message.mailbox_id)?.email_address
+ }
+ onClick={() => handleMessageClick(message)}
+ />
+ ))}
+
+ >
+ )}
+
+
+ {/* ── Paginazione ── */}
+ {totalPages > 1 && (
+
+
+ Pagina {page} di {totalPages} ({total} risultati)
+
+
+ setPage((p) => p - 1)}
+ >
+
+
+ = totalPages}
+ onClick={() => setPage((p) => p + 1)}
+ >
+
+
+
+
+ )}
+
+ )
+}
+
+// ─── Riga singolo risultato ───────────────────────────────────────────────────
+
+interface SearchResultRowProps {
+ message: MessageResponse
+ searchTerm: string
+ mailboxName?: string
+ onClick: () => void
+}
+
+function SearchResultRow({ message, searchTerm, mailboxName, onClick }: SearchResultRowProps) {
+ const isUnread = !message.is_read && message.direction === 'inbound'
+
+ // Evidenzia il termine cercato nel testo
+ const highlight = (text: string | null | undefined, term: string): React.ReactNode => {
+ if (!text || !term) return text || ''
+ const idx = text.toLowerCase().indexOf(term.toLowerCase())
+ if (idx === -1) return truncate(text, 150)
+ const start = Math.max(0, idx - 60)
+ const end = Math.min(text.length, idx + term.length + 90)
+ const snippet = (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '')
+ const snipIdx = snippet.toLowerCase().indexOf(term.toLowerCase())
+ if (snipIdx === -1) return snippet
+ return (
+ <>
+ {snippet.slice(0, snipIdx)}
+
+ {snippet.slice(snipIdx, snipIdx + term.length)}
+
+ {snippet.slice(snipIdx + term.length)}
+ >
+ )
+ }
+
+ return (
+
+ {/* Icona direzione */}
+
+ {message.direction === 'inbound' ? (
+ isUnread ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ {/* Contenuto */}
+
+ {/* Riga 1: mittente + badge casella + stato + data */}
+
+
+
+ {message.direction === 'inbound'
+ ? (message.from_address || 'Mittente sconosciuto')
+ : (message.to_addresses?.[0] || 'Destinatario sconosciuto')}
+
+ {mailboxName && (
+
+ {mailboxName}
+
+ )}
+
+
+
+
+ {formatRelative(message.received_at || message.sent_at || message.created_at)}
+
+
+
+
+ {/* Riga 2: oggetto */}
+
+ {isUnread &&
}
+
+ {searchTerm
+ ? highlight(message.subject || '(nessun oggetto)', searchTerm)
+ : truncate(message.subject || '(nessun oggetto)', 100)}
+
+
+
+ {/* Riga 3: snippet corpo */}
+ {message.body_text && (
+
+ {searchTerm ? highlight(message.body_text, searchTerm) : truncate(message.body_text, 150)}
+
+ )}
+
+ {/* Tag */}
+ {message.labels && message.labels.length > 0 && (
+
+
+
+ )}
+
+
+ {/* Indicatore allegati */}
+ {message.has_attachments && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/Settings/SettingsPage.tsx b/frontend/src/pages/Settings/SettingsPage.tsx
index ce2e7ea..150972b 100644
--- a/frontend/src/pages/Settings/SettingsPage.tsx
+++ b/frontend/src/pages/Settings/SettingsPage.tsx
@@ -1,14 +1,15 @@
/**
- * SettingsPage – impostazioni profilo utente e configurazione tenant (admin).
+ * SettingsPage - impostazioni profilo utente e configurazione tenant (admin).
*
* Sezioni:
* - Informazioni profilo (nome visualizzato, email, ruolo)
* - Modifica nome
* - Cambio password
- * - [Solo admin] Archiviazione Sostitutiva – toggle mock/produzione + credenziali conservatore
+ * - [Solo admin] Archiviazione Sostitutiva - toggle mock/produzione + credenziali conservatore
+ * - [Solo admin] Indicizzazione Full-Text - statistiche, reindex, monitoraggio job
*/
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import {
Settings,
User,
@@ -23,33 +24,72 @@ import {
EyeOff,
FlaskConical,
Zap,
+ Search,
+ RefreshCw,
+ XCircle,
+ Clock,
+ FileText,
+ BarChart3,
+ AlertCircle,
+ CheckCircle2,
+ Loader2,
} from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
import { useAuthStore } from '@/store/auth.store'
import { usersApi } from '@/api/users.api'
-import { settingsApi, type TenantSettingsResponse, type ArchivalMode } from '@/api/settings.api'
+import {
+ settingsApi,
+ type TenantSettingsResponse,
+ type ArchivalMode,
+ type IndexingStats,
+ type IndexingJobStatus,
+ type ReindexMode,
+} from '@/api/settings.api'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Card } from '@/components/ui/Card'
import toast from 'react-hot-toast'
-// ─── Etichetta ruolo ─────────────────────────────────────────────────────────
+// ─── Utility ─────────────────────────────────────────────────────────────────
function roleLabel(role: string): string {
switch (role) {
- case 'super_admin':
- return 'Super Amministratore'
- case 'admin':
- return 'Amministratore'
- case 'operator':
- return 'Operatore'
- default:
- return role
+ case 'super_admin': return 'Super Amministratore'
+ case 'admin': return 'Amministratore'
+ case 'operator': return 'Operatore'
+ default: return role
}
}
-// ─── Badge modalità archiviazione ────────────────────────────────────────────
+function formatElapsed(seconds: number | null): string {
+ if (seconds === null || seconds < 0) return '-'
+ if (seconds < 60) return `${seconds}s`
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
+ const h = Math.floor(seconds / 3600)
+ const m = Math.floor((seconds % 3600) / 60)
+ return `${h}h ${m}m`
+}
+
+function formatDatetime(iso: string | null): string {
+ if (!iso) return '-'
+ return new Date(iso).toLocaleString('it-IT', {
+ day: '2-digit', month: '2-digit', year: 'numeric',
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
+ })
+}
+
+function modeLabel(mode: ReindexMode | null): string {
+ if (!mode) return '-'
+ return mode === 'full' ? 'Totale' : 'Differenziale'
+}
+
+function rescanModeLabel(mode: string | null): string {
+ if (!mode) return '-'
+ return mode === 'force' ? 'Forzata (tutti)' : 'Differenziale'
+}
+
+// ─── Badge modalita' archiviazione ───────────────────────────────────────────
function ArchivalModeBadge({ mode }: { mode: ArchivalMode }) {
if (mode === 'production') {
@@ -68,12 +108,856 @@ function ArchivalModeBadge({ mode }: { mode: ArchivalMode }) {
)
}
-// ─── Pagina ──────────────────────────────────────────────────────────────────
+// ─── Badge stato job ──────────────────────────────────────────────────────────
+
+function JobStatusBadge({ status, isStale }: { status: string; isStale: boolean }) {
+ if (status === 'running') {
+ return (
+
+ {isStale
+ ?
+ :
+ }
+ {isStale ? 'Bloccato?' : 'In esecuzione'}
+
+ )
+ }
+ if (status === 'completed') {
+ return (
+
+
+ Completato
+
+ )
+ }
+ if (status === 'failed') {
+ return (
+
+
+ Errore
+
+ )
+ }
+ if (status === 'cancelled') {
+ return (
+
+
+ Annullato
+
+ )
+ }
+ // idle
+ return (
+
+
+ Inattivo
+
+ )
+}
+
+// ─── Barra di avanzamento ────────────────────────────────────────────────────
+
+function ProgressBar({
+ value,
+ max = 100,
+ color = 'blue',
+ animated = false,
+}: {
+ value: number
+ max?: number
+ color?: 'blue' | 'green' | 'amber' | 'red' | 'gray'
+ animated?: boolean
+}) {
+ const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
+ const colorClass = {
+ blue: 'bg-blue-500',
+ green: 'bg-green-500',
+ amber: 'bg-amber-500',
+ red: 'bg-red-500',
+ gray: 'bg-gray-400',
+ }[color]
+
+ return (
+
+ )
+}
+
+// ─── Sezione Indicizzazione ───────────────────────────────────────────────────
+
+interface IndexingSectionProps {
+ isSuperAdmin: boolean
+}
+
+function IndexingSection({ isSuperAdmin }: IndexingSectionProps) {
+ const [expanded, setExpanded] = useState(false)
+ const [stats, setStats] = useState(null)
+ const [jobStatus, setJobStatus] = useState(null)
+ const [rescanStatus, setRescanStatus] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [actionLoading, setActionLoading] = useState(false)
+ const [rescanActionLoading, setRescanActionLoading] = useState(false)
+ const [showCancelConfirm, setShowCancelConfirm] = useState(false)
+ const [showFullReindexConfirm, setShowFullReindexConfirm] = useState(false)
+ const [showRescanCancelConfirm, setShowRescanCancelConfirm] = useState(false)
+ const [showForceRescanConfirm, setShowForceRescanConfirm] = useState(false)
+
+ // Polling reindex e rescan separati
+ const pollingRef = useRef | null>(null)
+ const rescanPollingRef = useRef | null>(null)
+
+ const isRunning = jobStatus?.status === 'running'
+ const isRescanRunning = rescanStatus?.status === 'running'
+ const anyJobRunning = isRunning || isRescanRunning
+
+ // Carica dati iniziali quando si espande la sezione
+ const loadData = async () => {
+ setLoading(true)
+ try {
+ const [s, j, r] = await Promise.all([
+ settingsApi.getIndexingStats(),
+ settingsApi.getIndexingStatus(),
+ settingsApi.getRescanStatus(),
+ ])
+ setStats(s)
+ setJobStatus(j)
+ setRescanStatus(r)
+ } catch {
+ toast.error('Errore durante il caricamento delle statistiche di indicizzazione')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Polling reindex
+ const refreshStatus = async () => {
+ try {
+ const j = await settingsApi.getIndexingStatus()
+ setJobStatus(j)
+ if (j.status !== 'running' && isRunning) {
+ const s = await settingsApi.getIndexingStats()
+ setStats(s)
+ }
+ } catch {
+ // Silenzioso
+ }
+ }
+
+ // Polling rescan
+ const refreshRescanStatus = async () => {
+ try {
+ const r = await settingsApi.getRescanStatus()
+ setRescanStatus(r)
+ if (r.status !== 'running' && isRescanRunning) {
+ const s = await settingsApi.getIndexingStats()
+ setStats(s)
+ }
+ } catch {
+ // Silenzioso
+ }
+ }
+
+ useEffect(() => {
+ if (expanded) {
+ loadData()
+ }
+ }, [expanded])
+
+ // Polling reindex
+ useEffect(() => {
+ if (isRunning) {
+ pollingRef.current = setInterval(refreshStatus, 3000)
+ } else {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ pollingRef.current = null
+ }
+ }
+ return () => {
+ if (pollingRef.current) clearInterval(pollingRef.current)
+ }
+ }, [isRunning])
+
+ // Polling rescan
+ useEffect(() => {
+ if (isRescanRunning) {
+ rescanPollingRef.current = setInterval(refreshRescanStatus, 3000)
+ } else {
+ if (rescanPollingRef.current) {
+ clearInterval(rescanPollingRef.current)
+ rescanPollingRef.current = null
+ }
+ }
+ return () => {
+ if (rescanPollingRef.current) clearInterval(rescanPollingRef.current)
+ }
+ }, [isRescanRunning])
+
+ const handleStartReindex = async (mode: ReindexMode) => {
+ setActionLoading(true)
+ setShowFullReindexConfirm(false)
+ try {
+ const j = await settingsApi.startReindex(mode)
+ setJobStatus(j)
+ toast.success(
+ mode === 'full'
+ ? 'Reindex totale avviato in background'
+ : 'Reindex differenziale avviato in background'
+ )
+ } catch (err: unknown) {
+ const msg = (err as { response?: { data?: { detail?: string } } })
+ ?.response?.data?.detail
+ toast.error(msg ?? 'Errore durante l\'avvio del reindex')
+ } finally {
+ setActionLoading(false)
+ }
+ }
+
+ const handleCancel = async () => {
+ setActionLoading(true)
+ setShowCancelConfirm(false)
+ try {
+ const j = await settingsApi.cancelReindex()
+ setJobStatus(j)
+ toast.success('Segnale di annullamento inviato. Il job si fermera\' al prossimo batch.')
+ } catch (err: unknown) {
+ const msg = (err as { response?: { data?: { detail?: string } } })
+ ?.response?.data?.detail
+ toast.error(msg ?? 'Errore durante l\'annullamento')
+ } finally {
+ setActionLoading(false)
+ }
+ }
+
+ const handleStartRescan = async (force: boolean) => {
+ setRescanActionLoading(true)
+ setShowForceRescanConfirm(false)
+ try {
+ const r = await settingsApi.startRescan(force)
+ setRescanStatus(r)
+ toast.success(
+ force
+ ? 'Riscansione forzata avviata in background'
+ : 'Riscansione allegati avviata in background'
+ )
+ } catch (err: unknown) {
+ const msg = (err as { response?: { data?: { detail?: string } } })
+ ?.response?.data?.detail
+ toast.error(msg ?? 'Errore durante l\'avvio della scansione allegati')
+ } finally {
+ setRescanActionLoading(false)
+ }
+ }
+
+ const handleCancelRescan = async () => {
+ setRescanActionLoading(true)
+ setShowRescanCancelConfirm(false)
+ try {
+ const r = await settingsApi.cancelRescan()
+ setRescanStatus(r)
+ toast.success('Segnale di annullamento inviato. La scansione si fermera\' al prossimo batch.')
+ } catch (err: unknown) {
+ const msg = (err as { response?: { data?: { detail?: string } } })
+ ?.response?.data?.detail
+ toast.error(msg ?? 'Errore durante l\'annullamento della scansione')
+ } finally {
+ setRescanActionLoading(false)
+ }
+ }
+
+ // Colore della barra di copertura
+ const coverageColor = (pct: number) => {
+ if (pct >= 90) return 'green' as const
+ if (pct >= 60) return 'amber' as const
+ return 'red' as const
+ }
+
+ return (
+
+ {/* Header collassabile */}
+ setExpanded((v) => !v)}
+ >
+
+
+
+ Indicizzazione Full-Text
+
+ {jobStatus && (
+
+ )}
+
+ {expanded
+ ?
+ :
+ }
+
+
+ {expanded && (
+
+ {loading ? (
+
+
+ Caricamento statistiche...
+
+ ) : (
+ <>
+ {/* ── Spiegazione ── */}
+
+
Come funziona l'indicizzazione
+
+ Ogni messaggio PEC contiene un vettore di ricerca full-text (search_vector)
+ generato automaticamente da oggetto, mittente, destinatari e corpo.
+ Il worker indicizza anche il testo estratto dagli allegati PDF e DOCX.
+ Il reindex rigenera questi vettori manualmente, utile dopo migrazioni o
+ in caso di messaggi non indicizzati.
+
+
+
+ {/* ── Statistiche messaggi ── */}
+ {stats && (
+
+
+
+
+ Copertura indicizzazione
+
+
+
+ Aggiorna
+
+
+
+ {/* Card statistiche messaggi */}
+
+
+
+ {stats.total_messages.toLocaleString('it-IT')}
+
+
Messaggi totali
+
+
+
+ {stats.indexed_messages.toLocaleString('it-IT')}
+
+
Indicizzati
+
+
0
+ ? 'bg-amber-50 border-amber-200'
+ : 'bg-gray-50 border-gray-200'
+ }`}>
+
0 ? 'text-amber-700' : 'text-gray-500'
+ }`}>
+ {stats.unindexed_messages.toLocaleString('it-IT')}
+
+
0 ? 'text-amber-600' : 'text-gray-500'
+ }`}>
+ Non indicizzati
+
+
+
+
+ {/* Barra copertura messaggi */}
+
+
+ Copertura messaggi
+ = 90 ? 'text-green-700' :
+ stats.coverage_pct >= 60 ? 'text-amber-700' : 'text-red-700'
+ }`}>
+ {stats.coverage_pct}%
+
+
+
+ {stats.unindexed_messages > 0 && (
+
+ {stats.unindexed_messages.toLocaleString('it-IT')} messaggi non hanno ancora
+ il vettore di ricerca. Usa il reindex differenziale per indicizzarli.
+
+ )}
+
+
+ {/* Sezione allegati */}
+ {stats.attachments_total > 0 && (
+
+
+
+
+
+ Allegati PDF/DOCX con testo estratto
+
+
+ {rescanStatus && (
+
+ )}
+
+
+
+ {stats.attachments_extracted.toLocaleString('it-IT')} / {stats.attachments_total.toLocaleString('it-IT')} allegati
+
+ {stats.attachments_pct}%
+
+
+ {stats.attachments_pct < 100 && (
+
+ {(stats.attachments_total - stats.attachments_extracted).toLocaleString('it-IT')} allegati non hanno ancora il testo estratto.
+ Usa Riscansiona allegati per elaborarli.
+
+ )}
+
+ Il testo degli allegati viene estratto automaticamente dal worker
+ durante la sincronizzazione IMAP. Il reindex include il testo
+ gia' estratto nei vettori di ricerca.
+
+
+ )}
+
+ )}
+
+
+
+ {/* ── Stato job in corso ── */}
+ {jobStatus && (
+
+
+
+
+ Stato indicizzazione
+
+ {isRunning && (
+
+ aggiornamento automatico ogni 3s
+
+ )}
+
+
+
+ {/* Riga stato */}
+
+
+
+ {jobStatus.mode && (
+
+ Modalita': {modeLabel(jobStatus.mode)}
+
+ )}
+
+ {isRunning && (
+
+ {jobStatus.processed.toLocaleString('it-IT')} / {jobStatus.total.toLocaleString('it-IT')}
+
+ )}
+
+
+ {/* Barra progresso (quando running o completed) */}
+ {(jobStatus.status === 'running' || jobStatus.status === 'completed') && jobStatus.total > 0 && (
+
+
+
+ {jobStatus.progress_pct}% completato
+ {jobStatus.elapsed_seconds !== null && (
+ Durata: {formatElapsed(jobStatus.elapsed_seconds)}
+ )}
+
+
+ )}
+
+ {/* Metadati job */}
+ {jobStatus.status !== 'idle' && (
+
+ {jobStatus.started_at && (
+
+ Avviato:
+ {formatDatetime(jobStatus.started_at)}
+
+ )}
+ {jobStatus.finished_at && (
+
+ Terminato:
+ {formatDatetime(jobStatus.finished_at)}
+
+ )}
+ {jobStatus.started_by && (
+
+ Avviato da:
+ {jobStatus.started_by}
+
+ )}
+
+ )}
+
+ {/* Alert stale */}
+ {jobStatus.is_stale && (
+
+
+
+
Job potenzialmente bloccato
+
+ Il job e' in esecuzione da {formatElapsed(jobStatus.elapsed_seconds)} e
+ potrebbe essere bloccato. Usa il pulsante "Termina indicizzazione" per
+ cancellarlo e riavviarlo.
+
+
+
+ )}
+
+ {/* Errore */}
+ {jobStatus.status === 'failed' && jobStatus.error && (
+
+ {jobStatus.error}
+
+ )}
+
+ {/* Info idle */}
+ {jobStatus.status === 'idle' && (
+
+ Nessun job di reindex in corso. Usa i pulsanti in basso per avviarne uno.
+
+ )}
+
+
+ )}
+
+ {/* ── Stato scansione allegati ── */}
+ {rescanStatus && (
+
+
+
+
+ Stato scansione allegati
+
+ {isRescanRunning && (
+
+ aggiornamento automatico ogni 3s
+
+ )}
+
+
+
+
+
+
+ {rescanStatus.mode && (
+
+ Modalita': {rescanModeLabel(rescanStatus.mode)}
+
+ )}
+
+ {isRescanRunning && (
+
+ {rescanStatus.processed.toLocaleString('it-IT')} / {rescanStatus.total.toLocaleString('it-IT')} allegati
+
+ )}
+
+
+ {(rescanStatus.status === 'running' || rescanStatus.status === 'completed') && rescanStatus.total > 0 && (
+
+
+
+ {rescanStatus.progress_pct}% completato
+ {rescanStatus.elapsed_seconds !== null && (
+ Durata: {formatElapsed(rescanStatus.elapsed_seconds)}
+ )}
+
+
+ )}
+
+ {rescanStatus.status !== 'idle' && (
+
+ {rescanStatus.started_at && (
+
+ Avviato:
+ {formatDatetime(rescanStatus.started_at)}
+
+ )}
+ {rescanStatus.finished_at && (
+
+ Terminato:
+ {formatDatetime(rescanStatus.finished_at)}
+
+ )}
+ {rescanStatus.started_by && (
+
+ Avviato da:
+ {rescanStatus.started_by}
+
+ )}
+
+ )}
+
+ {rescanStatus.status === 'failed' && rescanStatus.error && (
+
+ {rescanStatus.error}
+
+ )}
+
+ {rescanStatus.status === 'idle' && (
+
+ Nessuna scansione allegati in corso. Usa il pulsante "Riscansiona allegati" per avviarne una.
+
+ )}
+
+
+ )}
+
+ {/* ── Dialogs conferma ── */}
+ {showCancelConfirm && (
+
+
+
+
Conferma annullamento reindex
+
+ Il job si fermera' alla fine del batch corrente.
+ I messaggi gia' indicizzati rimarranno indicizzati.
+
+
+
+ Confermo, annulla
+
+ ·
+ setShowCancelConfirm(false)}>
+ No, lascia proseguire
+
+
+
+
+ )}
+
+ {showFullReindexConfirm && (
+
+
+
+
Reindex totale
+
+ Verra' riscritto il vettore di ricerca per tutti i{' '}
+ {stats?.total_messages.toLocaleString('it-IT')} messaggi.
+ La ricerca rimane disponibile durante il processo.
+
+
+ handleStartReindex('full')}>
+ Confermo, avvia
+
+ ·
+ setShowFullReindexConfirm(false)}>
+ Annulla
+
+
+
+
+ )}
+
+ {showForceRescanConfirm && (
+
+
+
+
Riscansione forzata allegati
+
+ Il testo verra' ri-estratto da tutti gli{' '}
+ {stats?.attachments_total.toLocaleString('it-IT')} allegati del tenant,
+ sovrascrivendo quelli gia' presenti. Operazione piu' lunga del differenziale.
+
+
+ handleStartRescan(true)}>
+ Confermo, avvia riscansione forzata
+
+ ·
+ setShowForceRescanConfirm(false)}>
+ Annulla
+
+
+
+
+ )}
+
+ {showRescanCancelConfirm && (
+
+
+
+
Conferma annullamento scansione
+
+ La scansione si fermera' alla fine del batch corrente.
+
+
+
+ Confermo, annulla
+
+ ·
+ setShowRescanCancelConfirm(false)}>
+ No, lascia proseguire
+
+
+
+
+ )}
+
+ {/* ── Pulsanti reindex ── */}
+
+
Reindex messaggi
+
+ handleStartReindex('differential')}
+ disabled={actionLoading || anyJobRunning}
+ className="flex items-center gap-1.5"
+ >
+
+ Reindex differenziale
+
+ setShowFullReindexConfirm(true)}
+ disabled={actionLoading || anyJobRunning || showFullReindexConfirm}
+ className="flex items-center gap-1.5"
+ >
+
+ Reindex totale
+
+ {isRunning && (
+ setShowCancelConfirm(true)}
+ disabled={actionLoading || showCancelConfirm}
+ className="flex items-center gap-1.5 text-red-600 border-red-300 hover:bg-red-50"
+ >
+
+ Termina indicizzazione
+
+ )}
+
+
+
+ {/* ── Pulsanti scansione allegati ── */}
+
+
Scansione allegati
+
+ handleStartRescan(false)}
+ disabled={rescanActionLoading || anyJobRunning}
+ className="flex items-center gap-1.5 text-blue-700 border-blue-300 hover:bg-blue-50"
+ >
+
+ Riscansiona allegati
+
+ setShowForceRescanConfirm(true)}
+ disabled={rescanActionLoading || anyJobRunning || showForceRescanConfirm}
+ className="flex items-center gap-1.5"
+ >
+
+ Riscansione forzata
+
+ {isRescanRunning && (
+ setShowRescanCancelConfirm(true)}
+ disabled={rescanActionLoading || showRescanCancelConfirm}
+ className="flex items-center gap-1.5 text-red-600 border-red-300 hover:bg-red-50"
+ >
+
+ Termina scansione
+
+ )}
+
+
+
+ {/* Legenda pulsanti */}
+
+
+ Reindex differenziale – Indicizza solo i
+ messaggi con search_vector NULL. Rapido, ideale per uso routinario.
+
+
+ Reindex totale – Riscrive il vettore di
+ ricerca per tutti i messaggi, includendo il testo degli allegati gia' estratti.
+
+
+ Riscansiona allegati – Scarica da MinIO
+ gli allegati senza testo estratto (PDF, DOCX, ecc.), ne estrae il testo e
+ aggiorna il vettore di ricerca. Differenziale: solo allegati non ancora elaborati.
+
+
+ Riscansione forzata – Come
+ riscansiona, ma ri-estrae il testo da tutti gli allegati (sovrascrive).
+ Utile dopo migrazioni o per correggere estrazioni errate.
+
+
+ >
+ )}
+
+ )}
+
+ )
+}
+
+// ─── Pagina principale ────────────────────────────────────────────────────────
export function SettingsPage() {
const { user } = useAuth()
const loadUser = useAuthStore((s) => s.loadUser)
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'
+ const isSuperAdmin = user?.role === 'super_admin'
/* ── Stato modifica nome ── */
const [fullName, setFullName] = useState(user?.full_name ?? '')
@@ -98,8 +982,6 @@ export function SettingsPage() {
const [archivalNotes, setArchivalNotes] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [savingArchival, setSavingArchival] = useState(false)
-
- // Conferma passaggio a produzione
const [showProductionConfirm, setShowProductionConfirm] = useState(false)
/* ── Carica impostazioni archiviazione ── */
@@ -114,12 +996,10 @@ export function SettingsPage() {
try {
const data = await settingsApi.get()
setArchivalSettings(data)
- // Popola form
setArchivalMode(data.archival_mode)
setConservatoreId(data.conservatore_id)
setConservatoreEndpoint(data.conservatore_endpoint ?? '')
setArchivalNotes(data.archival_notes ?? '')
- // Username/password non vengono mai restituiti in chiaro
} catch {
toast.error('Errore durante il caricamento delle impostazioni di archiviazione')
} finally {
@@ -131,7 +1011,7 @@ export function SettingsPage() {
const handleSaveName = async () => {
if (!user) return
if (!fullName.trim()) {
- toast.error('Il nome non può essere vuoto')
+ toast.error('Il nome non puo\' essere vuoto')
return
}
setSavingName(true)
@@ -170,10 +1050,9 @@ export function SettingsPage() {
}
}
- /* ── Cambio modalità archiviazione ── */
+ /* ── Cambio modalita' archiviazione ── */
const handleModeToggle = (newMode: ArchivalMode) => {
if (newMode === 'production' && archivalMode === 'mock') {
- // Chiedi conferma prima di passare a produzione
setShowProductionConfirm(true)
} else {
setArchivalMode(newMode)
@@ -183,9 +1062,8 @@ export function SettingsPage() {
/* ── Salva impostazioni archiviazione ── */
const handleSaveArchival = async () => {
- // Validazione client-side per modalità produzione
if (archivalMode === 'production' && !conservatoreEndpoint.trim()) {
- toast.error('La modalità produzione richiede un URL endpoint del conservatore')
+ toast.error('La modalita\' produzione richiede un URL endpoint del conservatore')
return
}
@@ -198,13 +1076,8 @@ export function SettingsPage() {
archival_notes: archivalNotes || undefined,
}
- // Includi credenziali solo se l'utente ha inserito qualcosa
- if (conservatoreUsername) {
- payload.conservatore_username = conservatoreUsername
- }
- if (conservatorePassword) {
- payload.conservatore_password = conservatorePassword
- }
+ if (conservatoreUsername) payload.conservatore_username = conservatoreUsername
+ if (conservatorePassword) payload.conservatore_password = conservatorePassword
const updated = await settingsApi.update(payload)
setArchivalSettings(updated)
@@ -212,15 +1085,14 @@ export function SettingsPage() {
setConservatoreId(updated.conservatore_id)
setConservatoreEndpoint(updated.conservatore_endpoint ?? '')
setArchivalNotes(updated.archival_notes ?? '')
- // Reset credenziali (non rimostrare in chiaro)
setConservatoreUsername('')
setConservatorePassword('')
setShowProductionConfirm(false)
toast.success(
updated.archival_mode === 'production'
- ? ' Archiviazione attivata in modalità PRODUZIONE'
- : ' Archiviazione impostata in modalità mock'
+ ? 'Archiviazione attivata in modalita\' PRODUZIONE'
+ : 'Archiviazione impostata in modalita\' mock'
)
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })
@@ -286,7 +1158,7 @@ export function SettingsPage() {
size="sm"
>
- {savingName ? 'Salvataggio…' : 'Salva'}
+ {savingName ? 'Salvataggio...' : 'Salva'}
@@ -329,7 +1201,7 @@ export function SettingsPage() {
disabled={savingPwd || !newPassword || !confirmPassword}
>
- {savingPwd ? 'Aggiornamento…' : 'Aggiorna password'}
+ {savingPwd ? 'Aggiornamento...' : 'Aggiorna password'}
@@ -359,25 +1231,23 @@ export function SettingsPage() {
{archivalExpanded && (
-
{loadingArchival ? (
- Caricamento impostazioni…
+ Caricamento impostazioni...
) : (
<>
- {/* ── Toggle modalità ── */}
+ {/* Toggle modalita' */}
-
Modalità conservatore
+
Modalita' conservatore
- In modalità mock le operazioni di versamento vengono
+ In modalita' mock le operazioni di versamento vengono
simulate localmente senza inviare dati a sistemi esterni. Attiva la
- modalità produzione solo dopo aver configurato l'endpoint
+ modalita' produzione solo dopo aver configurato l'endpoint
e le credenziali del conservatore AgID.
- {/* Bottone Mock */}
handleModeToggle('mock')}
@@ -392,12 +1262,9 @@ export function SettingsPage() {
Mock
Simulazione locale
- {archivalMode === 'mock' && (
-
- )}
+ {archivalMode === 'mock' &&
}
- {/* Bottone Produzione */}
handleModeToggle('production')}
@@ -412,37 +1279,30 @@ export function SettingsPage() {
Produzione
Conservatore reale AgID
- {archivalMode === 'production' && (
-
- )}
+ {archivalMode === 'production' &&
}
- {/* Banner conferma passaggio a produzione */}
{showProductionConfirm && archivalMode === 'mock' && (
- Stai per attivare la modalità produzione
+ Stai per attivare la modalita' produzione
I versamenti verranno inviati al conservatore AgID reale.
- Assicurati che l'endpoint e le credenziali siano corretti.
{
- setArchivalMode('production')
- setShowProductionConfirm(false)
- }}
+ className="text-xs font-medium text-amber-800 underline"
+ onClick={() => { setArchivalMode('production'); setShowProductionConfirm(false) }}
>
Confermo, attiva produzione
·
setShowProductionConfirm(false)}
>
Annulla
@@ -455,7 +1315,6 @@ export function SettingsPage() {
- {/* ── Identificativo conservatore ── */}
- {/* ── Endpoint (mostrato sempre, obbligatorio in produzione) ── */}
URL endpoint API conservatore
- {archivalMode === 'production' && (
- *
- )}
+ {archivalMode === 'production' && * }
setConservatoreEndpoint(e.target.value)}
placeholder="https://conservatore.provider.it/api/v1"
- className={
- archivalMode === 'production' && !conservatoreEndpoint
- ? 'border-red-300 focus:ring-red-400'
- : ''
- }
/>
- {archivalMode === 'mock' && (
-
- Non utilizzato in modalità mock – puoi configurarlo in anticipo.
-
- )}
- {archivalMode === 'production' && !conservatoreEndpoint && (
-
- Obbligatorio per la modalità produzione
-
- )}
- {/* ── Credenziali (mostrate sempre, più evidenti in produzione) ── */}
-
- Credenziali conservatore
-
+
Credenziali conservatore
Vengono salvate cifrate (AES-256-GCM).
{archivalSettings?.conservatore_username_configured && (
-
- Username configurata
-
+ Username configurata
)}
{archivalSettings?.conservatore_password_configured && (
-
- Password configurata
-
+ Password configurata
)}
@@ -527,11 +1358,7 @@ export function SettingsPage() {
id="conservatore_username"
value={conservatoreUsername}
onChange={(e) => setConservatoreUsername(e.target.value)}
- placeholder={
- archivalSettings?.conservatore_username_configured
- ? '(credenziale già salvata – lascia vuoto per mantenerla)'
- : 'Inserisci username conservatore'
- }
+ placeholder={archivalSettings?.conservatore_username_configured ? '(credenziale gia\' salvata)' : 'Inserisci username'}
autoComplete="off"
/>
@@ -544,11 +1371,7 @@ export function SettingsPage() {
type={showPassword ? 'text' : 'password'}
value={conservatorePassword}
onChange={(e) => setConservatorePassword(e.target.value)}
- placeholder={
- archivalSettings?.conservatore_password_configured
- ? '(credenziale già salvata – lascia vuoto per mantenerla)'
- : 'Inserisci password conservatore'
- }
+ placeholder={archivalSettings?.conservatore_password_configured ? '(credenziale gia\' salvata)' : 'Inserisci password'}
autoComplete="new-password"
className="pr-10"
/>
@@ -557,16 +1380,12 @@ export function SettingsPage() {
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={() => setShowPassword((v) => !v)}
>
- {showPassword
- ?
- :
- }
+ {showPassword ? : }
- {/* ── Note operative ── */}
Note operative (opzionale)
- {/* ── Riepilogo stato corrente ── */}
{archivalSettings && (
-
- Configurazione attuale (salvata)
-
+
Configurazione attuale (salvata)
-
- Modalità:{' '}
-
- {archivalSettings.archival_mode === 'production'
- ? 'Produzione'
- : 'Mock (simulazione)'}
-
-
-
- Conservatore ID: {archivalSettings.conservatore_id}
-
+ Modalita': {archivalSettings.archival_mode === 'production' ? 'Produzione' : 'Mock'}
+ Conservatore ID: {archivalSettings.conservatore_id}
{archivalSettings.conservatore_endpoint && (
-
- Endpoint:{' '}
-
- {archivalSettings.conservatore_endpoint}
-
-
+ Endpoint: {archivalSettings.conservatore_endpoint}
)}
-
- Credenziali:{' '}
- {archivalSettings.conservatore_username_configured &&
- archivalSettings.conservatore_password_configured
- ? Configurate
+ Credenziali: {
+ archivalSettings.conservatore_username_configured && archivalSettings.conservatore_password_configured
+ ? Configurate
: Non configurate
- }
-
+ }
)}
- {/* ── Pulsante salva ── */}
- {savingArchival
- ? 'Salvataggio…'
- : archivalMode === 'production'
- ? 'Salva e attiva produzione'
- : 'Salva impostazioni'}
+ {savingArchival ? 'Salvataggio...' : archivalMode === 'production' ? 'Salva e attiva produzione' : 'Salva impostazioni'}
>
@@ -646,6 +1436,11 @@ export function SettingsPage() {
)}
)}
+
+ {/* ── Card: Indicizzazione Full-Text (solo admin) ── */}
+ {isAdmin && (
+
+ )}
)
}
diff --git a/frontend/src/pages/Templates/TemplatesPage.tsx b/frontend/src/pages/Templates/TemplatesPage.tsx
new file mode 100644
index 0000000..0b05d40
--- /dev/null
+++ b/frontend/src/pages/Templates/TemplatesPage.tsx
@@ -0,0 +1,228 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Plus, Pencil, Trash2, FileText, Search } from 'lucide-react'
+import toast from 'react-hot-toast'
+import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Input'
+import { Label } from '@/components/ui/Label'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
+import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
+import { templatesApi, type TemplateResponse, type TemplateCreate } from '@/api/templates.api'
+import { getErrorMessage } from '@/api/client'
+import { formatDate } from '@/lib/utils'
+import { useAuth } from '@/hooks/useAuth'
+
+export function TemplatesPage() {
+ const queryClient = useQueryClient()
+ const { isAdmin } = useAuth()
+ const [q, setQ] = useState('')
+ const [showForm, setShowForm] = useState(false)
+ const [editing, setEditing] = useState(null)
+
+ // Form state
+ const [formName, setFormName] = useState('')
+ const [formDescription, setFormDescription] = useState('')
+ const [formSubject, setFormSubject] = useState('')
+ const [formBody, setFormBody] = useState('')
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['templates', q],
+ queryFn: () => templatesApi.list(q || undefined),
+ })
+
+ const createMutation = useMutation({
+ mutationFn: (data: TemplateCreate) => templatesApi.create(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['templates'] })
+ toast.success('Template creato')
+ closeForm()
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: ({ id, data }: { id: string; data: TemplateCreate }) =>
+ templatesApi.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['templates'] })
+ toast.success('Template aggiornato')
+ closeForm()
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => templatesApi.delete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['templates'] })
+ toast.success('Template eliminato')
+ },
+ onError: (e) => toast.error(getErrorMessage(e)),
+ })
+
+ const openCreate = () => {
+ setEditing(null)
+ setFormName('')
+ setFormDescription('')
+ setFormSubject('')
+ setFormBody('')
+ setShowForm(true)
+ }
+
+ const openEdit = (t: TemplateResponse) => {
+ setEditing(t)
+ setFormName(t.name)
+ setFormDescription(t.description ?? '')
+ setFormSubject(t.subject)
+ setFormBody(t.body_html ?? t.body_text ?? '')
+ setShowForm(true)
+ }
+
+ const closeForm = () => {
+ setShowForm(false)
+ setEditing(null)
+ }
+
+ const handleSubmit = () => {
+ if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
+ const payload: TemplateCreate = {
+ name: formName.trim(),
+ description: formDescription.trim() || null,
+ subject: formSubject.trim(),
+ body_html: formBody || null,
+ body_text: null,
+ }
+ if (editing) {
+ updateMutation.mutate({ id: editing.id, data: payload })
+ } else {
+ createMutation.mutate(payload)
+ }
+ }
+
+ const items = data?.items ?? []
+
+ return (
+
+
+
+
Template messaggi
+
+ Template riutilizzabili per la composizione PEC
+
+
+ {isAdmin && (
+
+
+ Nuovo template
+
+ )}
+
+
+
+ {/* Ricerca */}
+
+
+ setQ(e.target.value)}
+ className="pl-9"
+ />
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+
+
Nessun template trovato
+
+ {isAdmin ? 'Crea il tuo primo template con il pulsante in alto.' : 'Nessun template disponibile.'}
+
+
+ ) : (
+
+ {items.map((t) => (
+
+
+
+
{t.name}
+ {t.description && (
+
{t.description}
+ )}
+
+ {isAdmin && (
+
+
openEdit(t)} className="h-8 w-8">
+
+
+
{
+ if (confirm(`Eliminare il template "${t.name}"?`)) {
+ deleteMutation.mutate(t.id)
+ }
+ }}
+ >
+
+
+
+ )}
+
+ {t.subject && (
+
+ Oggetto: {t.subject}
+
+ )}
+
+ Aggiornato: {formatDate(t.updated_at)}
+
+
+ ))}
+
+ )}
+
+
+ {/* Dialog form */}
+
!o && closeForm()}>
+
+
+ {editing ? 'Modifica template' : 'Nuovo template'}
+
+
+
+
+ Nome *
+ setFormName(e.target.value)} placeholder="Es. Risposta a ricorso" />
+
+
+ Descrizione (opzionale)
+ setFormDescription(e.target.value)} placeholder="Breve descrizione del template" />
+
+
+ Oggetto predefinito
+ setFormSubject(e.target.value)} placeholder="Oggetto del messaggio PEC" />
+
+
+
Corpo del messaggio
+
+
+
+
+
+
+
+ Annulla
+
+ {editing ? 'Salva modifiche' : 'Crea template'}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts
index 8ba1a63..a06e14a 100644
--- a/frontend/src/types/api.types.ts
+++ b/frontend/src/types/api.types.ts
@@ -260,6 +260,12 @@ export interface MessageResponse {
is_starred: boolean
is_archived: boolean
archived_at: string | null
+ is_trashed: boolean
+ trashed_at: string | null
+ is_pending_conservation: boolean
+ pending_conservation_at: string | null
+ is_conserved: boolean
+ conserved_at: string | null
raw_eml_path: string | null
created_at: string
updated_at: string
@@ -340,6 +346,7 @@ export interface PermissionGrantRequest {
can_read?: boolean
can_send?: boolean
can_manage?: boolean
+ can_conserve?: boolean
}
export interface MailboxUserPermissionResponse {
@@ -350,6 +357,7 @@ export interface MailboxUserPermissionResponse {
can_read: boolean
can_send: boolean
can_manage: boolean
+ can_conserve: boolean
granted_at: string
}
@@ -360,6 +368,7 @@ export interface UserMailboxPermissionResponse {
can_read: boolean
can_send: boolean
can_manage: boolean
+ can_conserve: boolean
}
// ─── Virtual Box ──────────────────────────────────────────────────────────────
@@ -576,6 +585,14 @@ export interface WsEvent {
// ─── API Pagination ──────────────────────────────────────────────────────────
+export interface PaginatedResponse {
+ items: T[]
+ total: number
+ page: number
+ page_size: number
+ pages: number
+}
+
export interface PaginationParams {
page?: number
page_size?: number
diff --git a/worker/Dockerfile b/worker/Dockerfile
index b70a5b7..dbcad3c 100644
--- a/worker/Dockerfile
+++ b/worker/Dockerfile
@@ -5,6 +5,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
curl \
+ tesseract-ocr \
+ tesseract-ocr-ita \
+ tesseract-ocr-eng \
+ poppler-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /worker
diff --git a/worker/app/imap/sync.py b/worker/app/imap/sync.py
index ca21e12..868f9b6 100644
--- a/worker/app/imap/sync.py
+++ b/worker/app/imap/sync.py
@@ -35,6 +35,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
+from app.jobs.dispatch_notification import evaluate_and_enqueue_notifications
+from app.jobs.index_message import index_message
from app.models import Attachment, Mailbox, Message
from app.parsers.eml_parser import parse_eml
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message
@@ -539,11 +541,15 @@ async def _save_message(
logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip")
return False
- # ── Parsing completo EML ──────────────────────────────────────────────────
- parsed = parse_eml(raw_eml)
- pec_class = classify_pec_message(
- parsed.raw_message or email.message_from_bytes(raw_eml)
- )
+ # ── Classificazione PEC da header (veloce, senza body) ───────────────────
+ # La classificazione avviene PRIMA del parsing completo perche' il parser
+ # deve sapere se il messaggio e' una ricevuta per evitare di sovrascrivere
+ # il body_text (testo della ricevuta) con il contenuto di postacert.eml.
+ quick_msg = email.message_from_bytes(raw_eml)
+ pec_class = classify_pec_message(quick_msg)
+
+ # ── Parsing completo EML (con is_receipt per proteggere il body) ──────────
+ parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt)
received_at = datetime.now(UTC)
# ── State machine: trova e aggiorna messaggio outbound ────────────────────
@@ -635,6 +641,50 @@ async def _save_message(
f"direction={direction!r} pec_type={pec_class.pec_type!r} "
f"subject={message.subject!r} allegati={len(parsed.attachments)}"
)
+
+ # ── Indicizzazione full-text (non bloccante, non interrompe la sync) ─────
+ # Chiamata dopo il flush degli allegati: index_message puo' leggere
+ # sia il messaggio che gli allegati dalla sessione corrente.
+ await index_message(message.id, db)
+
+ # ── Valutazione e accodamento notifiche (non bloccante) ───────────────────
+ # Solo per messaggi inbound: le ricevute PEC e la posta in arrivo
+ # possono triggerare regole di notifica configurate dal tenant.
+ # I messaggi outbound (Sent) non generano notifiche automatiche.
+ if direction == "inbound":
+ await evaluate_and_enqueue_notifications(
+ message=message,
+ mailbox=mailbox,
+ db=db,
+ redis_client=redis_client,
+ )
+
+ # ── Regole di smistamento automatico (Feature 2) ──────────────────────────
+ # Solo per messaggi inbound posta_certificata (non ricevute di sistema).
+ if direction == "inbound" and pec_class.pec_type == "posta_certificata":
+ try:
+ await redis_client.enqueue_job("apply_routing_rules", str(message.id))
+ except Exception as e:
+ logger.warning(f"[{mailbox.email_address}] Impossibile enqueue apply_routing_rules: {e}")
+
+ # ── Auto-save mittente nella rubrica (Feature 6) ──────────────────────────
+ # Per messaggi inbound di tipo posta_certificata, salva automaticamente
+ # il mittente nella rubrica pec_contacts del tenant (upsert idempotente).
+ if direction == "inbound" and pec_class.pec_type == "posta_certificata" and message.from_address:
+ try:
+ from sqlalchemy import text as _text
+ await db.execute(
+ _text("""
+ INSERT INTO pec_contacts (id, tenant_id, email, auto_saved, created_at, updated_at)
+ VALUES (gen_random_uuid(), :tenant_id, :email, true, now(), now())
+ ON CONFLICT (tenant_id, email) DO NOTHING
+ """),
+ {"tenant_id": str(mailbox.tenant_id), "email": message.from_address.lower().strip()},
+ )
+ await db.flush()
+ except Exception as e:
+ logger.debug(f"[{mailbox.email_address}] Auto-save contatto fallito (non critico): {e}")
+
return True
diff --git a/worker/app/jobs/apply_routing_rules.py b/worker/app/jobs/apply_routing_rules.py
new file mode 100644
index 0000000..d71273b
--- /dev/null
+++ b/worker/app/jobs/apply_routing_rules.py
@@ -0,0 +1,234 @@
+"""
+Job arq: apply_routing_rules – applica le regole di smistamento automatico.
+
+Viene accodato da sync.py dopo il salvataggio di ogni messaggio inbound.
+
+Logica:
+ 1. Carica le regole attive del tenant ordinate per priority
+ 2. Per ogni regola valuta le condizioni (AND)
+ 3. Se match: esegue le azioni (apply_label, mark_read, mark_starred, notify_webhook)
+ 4. Se stop_processing=True, interrompe la catena
+"""
+
+import logging
+import re
+import uuid as uuid_module
+from typing import Any
+
+from sqlalchemy import select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import AsyncSessionLocal
+from app.models import Message
+
+logger = logging.getLogger(__name__)
+
+
+# ─── Job principale ───────────────────────────────────────────────────────────
+
+async def apply_routing_rules(ctx: dict[str, Any], message_id: str) -> dict:
+ """
+ Valuta le regole di smistamento automatico per un messaggio.
+
+ Args:
+ ctx: contesto arq
+ message_id: UUID del messaggio da processare
+
+ Returns:
+ dict con: matched_rules, actions_applied
+ """
+ msg_uuid = uuid_module.UUID(message_id)
+
+ async with AsyncSessionLocal() as db:
+ # Carica il messaggio
+ msg = await db.get(Message, msg_uuid)
+ if not msg:
+ logger.warning(f"[routing_rules] Messaggio {message_id} non trovato")
+ return {"status": "skipped", "reason": "message_not_found"}
+
+ # Solo messaggi inbound di tipo posta_certificata
+ if msg.direction != "inbound" or msg.pec_type != "posta_certificata":
+ return {"status": "skipped", "reason": "not_inbound_pec"}
+
+ # Carica regole attive del tenant ordinate per priority ASC
+ rules_result = await db.execute(
+ text("""
+ SELECT r.id, r.name, r.priority, r.stop_processing,
+ COALESCE(
+ json_agg(
+ json_build_object('field', c.field, 'operator', c.operator, 'value', c.value)
+ ORDER BY c.id
+ ) FILTER (WHERE c.id IS NOT NULL),
+ '[]'::json
+ ) AS conditions,
+ COALESCE(
+ json_agg(
+ json_build_object('action_type', a.action_type, 'action_value', a.action_value)
+ ORDER BY a.id
+ ) FILTER (WHERE a.id IS NOT NULL),
+ '[]'::json
+ ) AS actions
+ FROM routing_rules r
+ LEFT JOIN routing_rule_conditions c ON c.rule_id = r.id
+ LEFT JOIN routing_rule_actions a ON a.rule_id = r.id
+ WHERE r.tenant_id = :tenant_id
+ AND r.is_active = true
+ GROUP BY r.id, r.name, r.priority, r.stop_processing
+ ORDER BY r.priority ASC
+ """),
+ {"tenant_id": str(msg.tenant_id)},
+ )
+ rules = rules_result.mappings().all()
+
+ matched_count = 0
+ actions_applied: list[str] = []
+
+ for rule in rules:
+ conditions = rule["conditions"]
+ if not conditions:
+ continue
+
+ # Valuta condizioni (AND)
+ if not _evaluate_conditions(msg, conditions):
+ continue
+
+ matched_count += 1
+ logger.info(
+ f"[routing_rules] Regola '{rule['name']}' (priority={rule['priority']}) "
+ f"match per messaggio {message_id}"
+ )
+
+ # Esegui azioni
+ for action in rule["actions"]:
+ applied = await _apply_action(db, msg, action["action_type"], action["action_value"])
+ if applied:
+ actions_applied.append(action["action_type"])
+
+ if rule["stop_processing"]:
+ break
+
+ if matched_count > 0:
+ await db.commit()
+
+ return {
+ "status": "ok",
+ "message_id": message_id,
+ "matched_rules": matched_count,
+ "actions_applied": actions_applied,
+ }
+
+
+# ─── Valutazione condizioni ────────────────────────────────────────────────────
+
+def _get_field_value(msg: Message, field: str) -> str:
+ if field == "from_address":
+ return (msg.from_address or "").lower()
+ elif field == "to_address":
+ return " ".join(msg.to_addresses or []).lower()
+ elif field == "subject":
+ return (msg.subject or "").lower()
+ elif field == "mailbox_id":
+ return str(msg.mailbox_id)
+ elif field == "pec_type":
+ return msg.pec_type or ""
+ return ""
+
+
+def _evaluate_condition(field_value: str, operator: str, value: str) -> bool:
+ v = value.lower()
+ fv = field_value.lower()
+ if operator == "contains":
+ return v in fv
+ elif operator == "not_contains":
+ return v not in fv
+ elif operator == "equals":
+ return fv == v
+ elif operator == "starts_with":
+ return fv.startswith(v)
+ elif operator == "ends_with":
+ return fv.endswith(v)
+ elif operator == "regex":
+ try:
+ return bool(re.search(value, field_value, re.IGNORECASE))
+ except re.error:
+ return False
+ return False
+
+
+def _evaluate_conditions(msg: Message, conditions: list[dict]) -> bool:
+ """Valuta AND tra tutte le condizioni."""
+ for cond in conditions:
+ field_val = _get_field_value(msg, cond["field"])
+ if not _evaluate_condition(field_val, cond["operator"], cond["value"]):
+ return False
+ return True
+
+
+# ─── Esecuzione azioni ─────────────────────────────────────────────────────────
+
+async def _apply_action(
+ db: AsyncSession,
+ msg: Message,
+ action_type: str,
+ action_value: str | None,
+) -> bool:
+ """Esegue una singola azione. Restituisce True se applicata."""
+ try:
+ if action_type == "apply_label" and action_value:
+ return await _action_apply_label(db, msg, uuid_module.UUID(action_value))
+ elif action_type == "mark_read":
+ if not msg.is_read:
+ msg.is_read = True
+ return True
+ elif action_type == "mark_starred":
+ if not msg.is_starred:
+ msg.is_starred = True
+ return True
+ elif action_type == "notify_webhook" and action_value:
+ await _action_notify_webhook(msg, action_value)
+ return True
+ except Exception as e:
+ logger.warning(f"[routing_rules] Errore azione {action_type}: {e}")
+ return False
+
+
+async def _action_apply_label(
+ db: AsyncSession, msg: Message, label_id: uuid_module.UUID
+) -> bool:
+ """Applica un'etichetta al messaggio se non gia' applicata."""
+ # Verifica che la label esista e appartenga al tenant
+ label_check = await db.execute(
+ text("SELECT id FROM labels WHERE id = :lid AND tenant_id = :tid"),
+ {"lid": str(label_id), "tid": str(msg.tenant_id)},
+ )
+ if not label_check.fetchone():
+ return False
+
+ # Inserisci con ON CONFLICT DO NOTHING per idempotenza
+ await db.execute(
+ text("""
+ INSERT INTO message_labels (message_id, label_id)
+ VALUES (:msg_id, :label_id)
+ ON CONFLICT DO NOTHING
+ """),
+ {"msg_id": str(msg.id), "label_id": str(label_id)},
+ )
+ logger.debug(f"[routing_rules] Etichetta {label_id} applicata a {msg.id}")
+ return True
+
+
+async def _action_notify_webhook(msg: Message, url: str) -> None:
+ """Invia notifica webhook per il messaggio."""
+ import aiohttp
+ payload = {
+ "event": "routing_rule_match",
+ "message_id": str(msg.id),
+ "subject": msg.subject,
+ "from_address": msg.from_address,
+ "pec_type": msg.pec_type,
+ }
+ try:
+ async with aiohttp.ClientSession() as session:
+ await session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=5))
+ except Exception as e:
+ logger.warning(f"[routing_rules] Webhook {url} fallito: {e}")
diff --git a/worker/app/jobs/dispatch_notification.py b/worker/app/jobs/dispatch_notification.py
new file mode 100644
index 0000000..5419fca
--- /dev/null
+++ b/worker/app/jobs/dispatch_notification.py
@@ -0,0 +1,666 @@
+"""
+Job arq: dispatch_notification – invio effettivo delle notifiche multi-canale.
+
+Flusso completo:
+ 1. sync.py salva un nuovo messaggio inbound
+ 2. sync.py chiama evaluate_and_enqueue_notifications() (questa funzione)
+ 3. evaluate_and_enqueue_notifications():
+ - legge le NotificationRule attive del tenant con event_type="new_message"
+ - applica i filtri opzionali (mailbox_id, direction, pec_type)
+ - per ogni regola che matcha: crea NotificationLog(status=pending)
+ - enqueue del job arq dispatch_notification con defer 5s
+ (per dare tempo al DB di committare prima che il job legga)
+ 4. dispatch_notification(ctx, notification_log_id):
+ - legge NotificationLog + NotificationChannel dal DB
+ - controlla circuit breaker
+ - decifra config_enc con AES-256-GCM
+ - chiama il sender appropriato (telegram/webhook/email/whatsapp)
+ - aggiorna status: sent / failed
+ - in caso di fallimento: re-enqueue con backoff esponenziale
+ - aggiorna circuit breaker dopo fallimenti consecutivi
+
+Circuit breaker:
+ - 5+ fallimenti consecutivi → apre per 1 ora
+ - reset automatico al primo successo
+
+Retry backoff (max_attempts default=3):
+ Tentativo 1 fallisce → attendi 5 min → tentativo 2
+ Tentativo 2 fallisce → attendi 30 min → tentativo 3
+ Tentativo 3 fallisce → FAILED definitivo
+
+Cifratura config_enc:
+ AES-256-GCM – stesso schema di notification_service.py nel backend.
+ Backward compatible: fallback a base64 grezzo per configurazioni pre-fix.
+"""
+
+import base64
+import json
+import logging
+import os
+import uuid
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from app.config import get_settings
+from app.database import AsyncSessionLocal
+from app.models import (
+ Mailbox,
+ Message,
+ NotificationChannel,
+ NotificationLog,
+ NotificationRule,
+)
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+# ─── Backoff retry (secondi) per tentativo N fallito (0-based) ───────────────
+_RETRY_DELAYS = [
+ 5 * 60, # dopo tentativo 1: 5 minuti
+ 30 * 60, # dopo tentativo 2: 30 minuti
+ 120 * 60, # dopo tentativo 3: 2 ore (non raggiunto: max_attempts=3)
+]
+
+# ─── Circuit breaker ──────────────────────────────────────────────────────────
+_CIRCUIT_FAILURE_THRESHOLD = 5 # fallimenti consecutivi per aprire il circuito
+_CIRCUIT_OPEN_DURATION = timedelta(hours=1)
+
+
+# ─── Cifratura AES-256-GCM ────────────────────────────────────────────────────
+
+def _decrypt_config(enc: str) -> dict:
+ """
+ Decifra config_enc AES-256-GCM.
+
+ Backward compatible: se non e' GCM valido, prova fallback base64 grezzo.
+ """
+ key = settings.encryption_key_bytes
+ try:
+ raw = base64.b64decode(enc.encode("ascii"))
+ if len(raw) > 28: # 12 nonce + 16 tag minimo
+ nonce = raw[:12]
+ ciphertext = raw[12:]
+ aesgcm = AESGCM(key)
+ plaintext = aesgcm.decrypt(nonce, ciphertext, None)
+ return json.loads(plaintext.decode("utf-8"))
+ except Exception:
+ pass
+
+ # Fallback: base64 grezzo (configurazioni pre-fix sicurezza)
+ try:
+ raw = base64.b64decode(enc.encode("ascii"))
+ return json.loads(raw.decode("utf-8"))
+ except Exception:
+ return {}
+
+
+# ─── Valutazione filtri regola ────────────────────────────────────────────────
+
+def _matches_filter(
+ filter_data: dict | None,
+ message: Message,
+ mailbox: Mailbox,
+) -> bool:
+ """
+ Valuta se un messaggio soddisfa i filtri opzionali di una regola.
+
+ Filtri supportati:
+ mailbox_id: str | list[str] – UUID casella
+ direction: str – "inbound" | "outbound"
+ pec_type: str | list[str] – tipo messaggio PEC
+ """
+ if not filter_data:
+ return True
+
+ # Filtro per mailbox
+ if "mailbox_id" in filter_data:
+ allowed = filter_data["mailbox_id"]
+ if isinstance(allowed, list):
+ if str(message.mailbox_id) not in [str(a) for a in allowed]:
+ return False
+ else:
+ if str(message.mailbox_id) != str(allowed):
+ return False
+
+ # Filtro per direzione
+ if "direction" in filter_data:
+ if message.direction != filter_data["direction"]:
+ return False
+
+ # Filtro per tipo PEC
+ if "pec_type" in filter_data:
+ allowed = filter_data["pec_type"]
+ if isinstance(allowed, list):
+ if message.pec_type not in allowed:
+ return False
+ else:
+ if message.pec_type != allowed:
+ return False
+
+ return True
+
+
+# ─── Costruzione testo notifica ───────────────────────────────────────────────
+
+def _build_notification_text(
+ event_type: str,
+ payload: dict,
+ channel_type: str,
+ channel_name: str = "",
+) -> str:
+ """
+ Costruisce il testo della notifica in base all'evento e al tipo canale.
+ """
+ if event_type == "new_message":
+ subject = payload.get("subject") or "(senza oggetto)"
+ from_addr = payload.get("from_address") or "mittente sconosciuto"
+ mailbox_email = payload.get("mailbox_email") or payload.get("mailbox_id", "")
+ pec_type = payload.get("pec_type", "posta_certificata")
+ received_at = payload.get("received_at", "")
+
+ # Traduzione tipo PEC
+ pec_type_labels = {
+ "posta_certificata": "PEC",
+ "accettazione": "Ricevuta di Accettazione",
+ "non_accettazione": "Ricevuta di Non Accettazione",
+ "presa_in_carico": "Ricevuta di Presa in Carico",
+ "avvenuta_consegna": "Ricevuta di Avvenuta Consegna",
+ "mancata_consegna": "Ricevuta di Mancata Consegna",
+ "errore_consegna": "Ricevuta di Errore Consegna",
+ "preavviso_mancata_consegna": "Preavviso Mancata Consegna",
+ "rilevazione_virus": "Rilevazione Virus",
+ "unknown": "Messaggio",
+ }
+ tipo_label = pec_type_labels.get(pec_type, pec_type)
+
+ if channel_type == "telegram":
+ return (
+ f"Nuovo messaggio PEC \n\n"
+ f"Tipo: {tipo_label}\n"
+ f"Da: {from_addr}\n"
+ f"Casella: {mailbox_email}\n"
+ f"Oggetto: {subject}"
+ )
+ else:
+ return (
+ f"Nuovo messaggio PEC\n"
+ f"Tipo: {tipo_label}\n"
+ f"Da: {from_addr}\n"
+ f"Casella: {mailbox_email}\n"
+ f"Oggetto: {subject}"
+ )
+
+ elif event_type == "state_changed":
+ message_id = payload.get("message_id", "")
+ old_state = payload.get("old_state", "")
+ new_state = payload.get("new_state", "")
+ subject = payload.get("subject", "")
+ return (
+ f"Cambio stato PEC\n"
+ f"Oggetto: {subject}\n"
+ f"Stato: {old_state} -> {new_state}"
+ )
+
+ else:
+ return f"Evento PEChub: {event_type}\n{json.dumps(payload, ensure_ascii=False, default=str)}"
+
+
+def _build_notification_payload(event_type: str, payload: dict) -> dict:
+ """Costruisce il payload JSON per il webhook."""
+ return {
+ "event": event_type,
+ "timestamp": datetime.now(UTC).isoformat(),
+ "data": payload,
+ }
+
+
+def _build_email_subject(event_type: str, payload: dict) -> str:
+ """Costruisce l'oggetto dell'email di notifica."""
+ if event_type == "new_message":
+ subject = payload.get("subject") or "(senza oggetto)"
+ return f"[PEChub] Nuovo messaggio PEC: {subject}"
+ elif event_type == "state_changed":
+ return f"[PEChub] Cambio stato PEC: {payload.get('new_state', '')}"
+ return f"[PEChub] Evento: {event_type}"
+
+
+# ─── Evaluate & Enqueue ───────────────────────────────────────────────────────
+
+async def evaluate_and_enqueue_notifications(
+ message: Message,
+ mailbox: Mailbox,
+ db: Any, # AsyncSession – evito import circolare
+ redis_client: Any, # ArqRedis
+) -> None:
+ """
+ Valuta le regole di notifica per un messaggio appena salvato e accoda i job.
+
+ Chiamata da sync.py dopo _save_message e index_message.
+ Non solleva eccezioni: gli errori vengono loggati ma non propagati per
+ non interrompere il flusso di sincronizzazione IMAP.
+
+ Args:
+ message: messaggio appena salvato nel DB (flush, non commit)
+ mailbox: casella di appartenenza
+ db: sessione DB (open, con flush del messaggio)
+ redis_client: ArqRedis per enqueue_job
+ """
+ try:
+ await _do_evaluate_and_enqueue(message, mailbox, db, redis_client)
+ except Exception as exc:
+ logger.error(
+ f"Errore evaluate_and_enqueue_notifications per messaggio "
+ f"{message.id}: {exc}",
+ exc_info=True,
+ )
+
+
+async def _do_evaluate_and_enqueue(
+ message: Message,
+ mailbox: Mailbox,
+ db: Any,
+ redis_client: Any,
+) -> None:
+ """Logica interna – puo' sollevare eccezioni."""
+
+ # Carica regole attive per questo tenant con event_type = "new_message"
+ rules_result = await db.execute(
+ select(NotificationRule)
+ .options(selectinload(NotificationRule.channel))
+ .where(
+ NotificationRule.tenant_id == message.tenant_id,
+ NotificationRule.is_active == True, # noqa: E712
+ NotificationRule.event_type == "new_message",
+ )
+ )
+ rules: list[NotificationRule] = list(rules_result.scalars().all())
+
+ if not rules:
+ return
+
+ enqueued_count = 0
+
+ for rule in rules:
+ channel = rule.channel
+ if not channel or not channel.is_active:
+ continue
+
+ # Controlla circuit breaker
+ if (
+ channel.circuit_open_until
+ and channel.circuit_open_until > datetime.now(UTC)
+ ):
+ logger.debug(
+ f"[notify] Canale {channel.name!r} circuit aperto fino a "
+ f"{channel.circuit_open_until}, skip regola {rule.name!r}"
+ )
+ continue
+
+ # Applica filtri
+ if not _matches_filter(rule.filter, message, mailbox):
+ continue
+
+ # Costruisce il payload dell'evento
+ event_payload = {
+ "message_id": str(message.id),
+ "mailbox_id": str(mailbox.id),
+ "mailbox_email": mailbox.email_address,
+ "subject": message.subject or "",
+ "from_address": message.from_address or "",
+ "to_addresses": list(message.to_addresses or []),
+ "pec_type": message.pec_type,
+ "direction": message.direction,
+ "received_at": (
+ message.received_at.isoformat()
+ if message.received_at
+ else None
+ ),
+ }
+
+ # Crea NotificationLog
+ log = NotificationLog(
+ id=uuid.uuid4(),
+ tenant_id=message.tenant_id,
+ channel_id=rule.channel_id,
+ rule_id=rule.id,
+ event_type="new_message",
+ event_payload=event_payload,
+ status="pending",
+ attempt_count=0,
+ max_attempts=3,
+ )
+ db.add(log)
+ await db.flush() # ottieni log.id
+
+ # Enqueue arq job con defer 5s per attendere il commit DB
+ try:
+ await redis_client.enqueue_job(
+ "dispatch_notification",
+ str(log.id),
+ _defer_by=timedelta(seconds=5),
+ )
+ enqueued_count += 1
+ logger.info(
+ f"[notify] Enqueued dispatch_notification per regola "
+ f"{rule.name!r} -> canale {channel.name!r} "
+ f"(log_id={log.id})"
+ )
+ except Exception as e:
+ logger.error(
+ f"[notify] Impossibile enqueue dispatch_notification "
+ f"per log {log.id}: {e}"
+ )
+
+ if enqueued_count > 0:
+ logger.info(
+ f"[notify] Messaggio {message.id}: "
+ f"{enqueued_count} notifiche accodate"
+ )
+
+
+# ─── Job arq principale ───────────────────────────────────────────────────────
+
+async def dispatch_notification(
+ ctx: dict[str, Any],
+ notification_log_id: str,
+) -> dict:
+ """
+ Job arq: legge un NotificationLog e invia la notifica al canale configurato.
+
+ Args:
+ ctx: contesto arq (ctx["redis"] = ArqRedis)
+ notification_log_id: UUID del NotificationLog da processare
+
+ Returns:
+ dict con status e dettagli
+ """
+ redis_client = ctx.get("redis")
+
+ async with AsyncSessionLocal() as db:
+ log_uuid = uuid.UUID(notification_log_id)
+
+ # ── Carica log + canale ───────────────────────────────────────────────
+ log_result = await db.execute(
+ select(NotificationLog)
+ .options(selectinload(NotificationLog.channel))
+ .where(NotificationLog.id == log_uuid)
+ )
+ log: NotificationLog | None = log_result.scalar_one_or_none()
+
+ if not log:
+ logger.warning(
+ f"[dispatch_notification] NotificationLog {notification_log_id} non trovato"
+ )
+ return {"status": "not_found", "log_id": notification_log_id}
+
+ if log.status in ("sent", "failed", "skipped"):
+ logger.debug(
+ f"[dispatch_notification] Log {notification_log_id} "
+ f"gia' in stato {log.status!r}, skip"
+ )
+ return {"status": "already_processed", "current_status": log.status}
+
+ channel = log.channel
+ if not channel or not channel.is_active:
+ log.status = "skipped"
+ await db.commit()
+ return {"status": "skipped", "reason": "channel_inactive"}
+
+ # ── Circuit breaker ───────────────────────────────────────────────────
+ if (
+ channel.circuit_open_until
+ and channel.circuit_open_until > datetime.now(UTC)
+ ):
+ log.status = "skipped"
+ await db.commit()
+ logger.info(
+ f"[dispatch_notification] Canale {channel.name!r} circuit aperto, "
+ f"log {notification_log_id} marcato skipped"
+ )
+ return {"status": "skipped", "reason": "circuit_open"}
+
+ # ── Incrementa contatore tentativi ────────────────────────────────────
+ log.attempt_count += 1
+ current_attempt = log.attempt_count
+
+ # ── Decifra config sensibile ──────────────────────────────────────────
+ secret: dict = {}
+ if channel.config_enc:
+ try:
+ secret = _decrypt_config(channel.config_enc)
+ except Exception as e:
+ log.status = "failed"
+ log.last_error = f"Errore decifratura config: {e}"
+ await db.commit()
+ logger.error(
+ f"[dispatch_notification] Errore decifratura canale "
+ f"{channel.name!r}: {e}"
+ )
+ return {"status": "failed", "error": str(e)}
+
+ config = channel.config or {}
+ payload = log.event_payload or {}
+ channel_type = channel.channel_type
+
+ # ── Testo notifica ────────────────────────────────────────────────────
+ notif_text = _build_notification_text(
+ event_type=log.event_type,
+ payload=payload,
+ channel_type=channel_type,
+ channel_name=channel.name,
+ )
+
+ # ── Dispatch al sender ────────────────────────────────────────────────
+ success = False
+ error_msg: str | None = None
+ http_status: int | None = None
+
+ try:
+ if channel_type == "telegram":
+ bot_token = secret.get("bot_token")
+ chat_id = str(config.get("chat_id", ""))
+ if not bot_token or not chat_id:
+ raise ValueError("bot_token o chat_id non configurati")
+
+ from app.notifications.telegram import send_message
+ result = await send_message(
+ bot_token=bot_token,
+ chat_id=chat_id,
+ text=notif_text,
+ parse_mode="HTML",
+ )
+ http_status = 200
+ success = True
+ logger.info(
+ f"[dispatch_notification] Telegram inviato: "
+ f"message_id={result.get('message_id')} "
+ f"canale={channel.name!r}"
+ )
+
+ elif channel_type == "webhook":
+ url = config.get("url")
+ if not url:
+ raise ValueError("URL webhook non configurato")
+ webhook_secret_val = secret.get("webhook_secret")
+
+ from app.notifications.webhook import send_webhook
+ result = await send_webhook(
+ url=url,
+ payload=_build_notification_payload(log.event_type, payload),
+ event_type=log.event_type,
+ webhook_secret=webhook_secret_val,
+ )
+ http_status = result.get("http_status")
+ success = True
+ logger.info(
+ f"[dispatch_notification] Webhook inviato: "
+ f"HTTP {http_status} delivery={result.get('delivery_id')} "
+ f"canale={channel.name!r}"
+ )
+
+ elif channel_type == "email":
+ smtp_host = config.get("smtp_host")
+ smtp_port = int(config.get("smtp_port", 465))
+ from_email = config.get("from_email")
+ to_email = config.get("to_email")
+ smtp_user = config.get("smtp_user") or from_email
+ use_tls = config.get("smtp_use_tls", True)
+ use_starttls = config.get("smtp_use_starttls", False)
+ from_name = config.get("from_name", "PEChub Notifiche")
+ smtp_password = secret.get("smtp_password", "")
+
+ if not smtp_host or not from_email or not to_email:
+ raise ValueError("Configurazione SMTP incompleta")
+
+ from app.notifications.email_smtp import send_email_notification
+ subject = _build_email_subject(log.event_type, payload)
+ await send_email_notification(
+ smtp_host=smtp_host,
+ smtp_port=smtp_port,
+ smtp_user=smtp_user,
+ smtp_password=smtp_password,
+ from_email=from_email,
+ to_email=to_email,
+ subject=subject,
+ body_text=notif_text,
+ body_html=None,
+ from_name=from_name,
+ use_tls=use_tls,
+ use_starttls=use_starttls,
+ )
+ http_status = 200
+ success = True
+ logger.info(
+ f"[dispatch_notification] Email inviata a {to_email} "
+ f"canale={channel.name!r}"
+ )
+
+ elif channel_type == "whatsapp":
+ phone_number_id = config.get("phone_number_id")
+ to_phone = config.get("to_phone")
+ access_token = secret.get("access_token")
+
+ if not phone_number_id or not to_phone or not access_token:
+ raise ValueError("Configurazione WhatsApp incompleta")
+
+ from app.notifications.whatsapp import send_whatsapp_message
+ result = await send_whatsapp_message(
+ phone_number_id=phone_number_id,
+ to_phone=to_phone,
+ text=notif_text,
+ access_token=access_token,
+ )
+ http_status = result.get("http_status")
+ success = True
+ logger.info(
+ f"[dispatch_notification] WhatsApp inviato: "
+ f"message_id={result.get('message_id')} "
+ f"canale={channel.name!r}"
+ )
+
+ else:
+ raise ValueError(f"Tipo canale non supportato: {channel_type!r}")
+
+ except Exception as exc:
+ error_msg = str(exc)
+ logger.warning(
+ f"[dispatch_notification] Tentativo {current_attempt} fallito "
+ f"canale={channel.name!r} tipo={channel_type!r}: {error_msg}"
+ )
+
+ # ── Aggiorna stato log e canale ───────────────────────────────────────
+
+ if success:
+ log.status = "sent"
+ log.sent_at = datetime.now(UTC)
+ log.http_status = http_status
+
+ # Reset circuit breaker
+ channel.consecutive_failures = 0
+ channel.circuit_open_until = None
+
+ await db.commit()
+ logger.info(
+ f"[dispatch_notification] Notifica {notification_log_id} INVIATA "
+ f"canale={channel.name!r} tipo={channel_type!r}"
+ )
+ return {
+ "status": "sent",
+ "log_id": notification_log_id,
+ "channel": channel.name,
+ "channel_type": channel_type,
+ "attempt": current_attempt,
+ }
+
+ else:
+ # ── Retry o failure definitivo ────────────────────────────────────
+ log.last_error = error_msg
+ log.http_status = http_status
+
+ if current_attempt < log.max_attempts:
+ # Calcola delay backoff
+ delay_idx = min(current_attempt - 1, len(_RETRY_DELAYS) - 1)
+ delay_seconds = _RETRY_DELAYS[delay_idx]
+ next_retry = datetime.now(UTC) + timedelta(seconds=delay_seconds)
+
+ log.next_retry_at = next_retry
+ # Mantieni status "pending" per il retry
+ await db.commit()
+
+ # Re-enqueue con backoff
+ if redis_client:
+ try:
+ await redis_client.enqueue_job(
+ "dispatch_notification",
+ notification_log_id,
+ _defer_by=timedelta(seconds=delay_seconds),
+ )
+ logger.info(
+ f"[dispatch_notification] Retry tentativo "
+ f"{current_attempt + 1} schedulato in {delay_seconds}s "
+ f"per log {notification_log_id}"
+ )
+ except Exception as enqueue_err:
+ logger.error(
+ f"[dispatch_notification] Impossibile re-enqueue "
+ f"log {notification_log_id}: {enqueue_err}"
+ )
+
+ return {
+ "status": "retry",
+ "log_id": notification_log_id,
+ "attempt": current_attempt,
+ "next_retry_in_seconds": delay_seconds,
+ "error": error_msg,
+ }
+
+ else:
+ # Tutti i tentativi esauriti → FAILED
+ log.status = "failed"
+
+ # Aggiorna circuit breaker canale
+ channel.consecutive_failures += 1
+ if channel.consecutive_failures >= _CIRCUIT_FAILURE_THRESHOLD:
+ channel.circuit_open_until = datetime.now(UTC) + _CIRCUIT_OPEN_DURATION
+ logger.warning(
+ f"[dispatch_notification] Circuit breaker aperto per "
+ f"canale {channel.name!r} "
+ f"({channel.consecutive_failures} fallimenti consecutivi)"
+ )
+
+ await db.commit()
+ logger.error(
+ f"[dispatch_notification] Notifica {notification_log_id} FALLITA "
+ f"definitivamente dopo {current_attempt} tentativi: {error_msg}"
+ )
+ return {
+ "status": "failed",
+ "log_id": notification_log_id,
+ "channel": channel.name,
+ "attempts": current_attempt,
+ "error": error_msg,
+ }
diff --git a/worker/app/jobs/index_message.py b/worker/app/jobs/index_message.py
new file mode 100644
index 0000000..c3c82b5
--- /dev/null
+++ b/worker/app/jobs/index_message.py
@@ -0,0 +1,718 @@
+"""
+Indicizzazione full-text dei messaggi PEC.
+
+Responsabilita':
+ 1. Scarica gli allegati da MinIO
+ 2. Estrae il testo in base al formato del file
+ 3. Aggiorna la colonna extracted_text in attachments
+ 4. Aggiorna la colonna search_vector in messages includendo il testo degli allegati
+
+Formati supportati:
+ - PDF (.pdf) tramite pypdf
+ - Word (.docx, .doc) tramite python-docx
+ - Excel (.xlsx, .xls) tramite openpyxl
+ - PowerPoint(.pptx, .ppt) tramite python-pptx
+ - LibreOffice (.odt, .ods, .odp) tramite odfpy
+ - RTF (.rtf) tramite striprtf
+ - Testo (.txt, .csv, .xml, .html, .htm) testo grezzo
+ - Email (.eml, .msg) tramite stdlib email
+ - Firmati (.p7m) unwrap CMS poi estrae in base all'estensione interna
+
+Viene chiamato alla fine di _save_message in sync.py, in modo non bloccante:
+un'eccezione qui non interrompe la sincronizzazione del messaggio.
+"""
+
+import io
+import logging
+import re
+import uuid
+
+from sqlalchemy import select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+logger = logging.getLogger(__name__)
+
+# Dimensione massima del testo estratto per allegato (caratteri)
+MAX_EXTRACTED_TEXT_LEN = 50_000
+# Dimensione massima del testo aggregato degli allegati per il search_vector
+MAX_COMBINED_TEXT_LEN = 200_000
+
+
+# ─── Rilevamento tipo file ────────────────────────────────────────────────────
+
+def _ext(filename: str | None) -> str:
+ """Restituisce l'estensione del file in minuscolo, senza punto."""
+ if not filename:
+ return ""
+ fn = filename.lower()
+ # Gestione doppia estensione es. documento.pdf.p7m
+ if fn.endswith(".p7m"):
+ return "p7m"
+ idx = fn.rfind(".")
+ return fn[idx + 1:] if idx >= 0 else ""
+
+
+def _is_extractable(content_type: str | None, filename: str | None) -> bool:
+ """Ritorna True se il formato e' supportato dall'estrattore."""
+ ct = (content_type or "").lower()
+ e = _ext(filename)
+ return e in _EXTRACTORS or ct in _CONTENT_TYPE_MAP
+
+
+def _resolve_extractor(content_type: str | None, filename: str | None):
+ """Ritorna la funzione estrattore appropriata, o None."""
+ e = _ext(filename)
+ if e in _EXTRACTORS:
+ return _EXTRACTORS[e]
+ ct = (content_type or "").lower()
+ if ct in _CONTENT_TYPE_MAP:
+ return _EXTRACTORS.get(_CONTENT_TYPE_MAP[ct])
+ return None
+
+
+# ─── Estrattori ───────────────────────────────────────────────────────────────
+
+# Soglia minima di caratteri estratti da pypdf prima di ricorrere all'OCR.
+# Un PDF di testo reale produce migliaia di caratteri; una scansione ne produce
+# zero o pochissimi (artefatti). 50 char e' un valore conservativo sicuro.
+_PDF_OCR_THRESHOLD = 50
+
+# Numero massimo di pagine su cui eseguire OCR per evitare timeout su PDF lunghi.
+_PDF_OCR_MAX_PAGES = 15
+
+
+def _extract_pdf(content: bytes) -> str:
+ """
+ Estrae testo da PDF tramite pypdf.
+
+ Se il testo estratto e' inferiore a _PDF_OCR_THRESHOLD caratteri (PDF
+ image-only / scansione), attiva il fallback OCR via Tesseract.
+ """
+ try:
+ import pypdf # type: ignore[import]
+ reader = pypdf.PdfReader(io.BytesIO(content))
+ parts: list[str] = []
+ for page in reader.pages:
+ try:
+ t = page.extract_text()
+ if t:
+ parts.append(t)
+ except Exception:
+ continue
+ text = " ".join(parts)
+ except ImportError:
+ logger.warning("pypdf non installato: impossibile estrarre testo da PDF")
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore estrazione PDF: {e}")
+ return ""
+
+ # Se il testo e' troppo corto, il PDF e' probabilmente una scansione
+ if len(text.strip()) < _PDF_OCR_THRESHOLD:
+ logger.debug(
+ f"PDF con testo insufficiente ({len(text.strip())} char), "
+ "tentativo OCR..."
+ )
+ ocr_text = _extract_pdf_ocr(content)
+ if len(ocr_text.strip()) > len(text.strip()):
+ return ocr_text
+
+ return text
+
+
+def _extract_pdf_ocr(content: bytes) -> str:
+ """
+ OCR su PDF image-only tramite pdf2image + Tesseract.
+
+ Converte le pagine del PDF in immagini PIL a 200 DPI (buon compromesso
+ qualita'/velocita' su CPU) e applica Tesseract con lingua italiana + inglese.
+ Processa al massimo _PDF_OCR_MAX_PAGES pagine per evitare timeout.
+ """
+ try:
+ from pdf2image import convert_from_bytes # type: ignore[import]
+ import pytesseract # type: ignore[import]
+
+ pages = convert_from_bytes(
+ content,
+ dpi=200,
+ last_page=_PDF_OCR_MAX_PAGES,
+ )
+ parts: list[str] = []
+ for page_img in pages:
+ try:
+ t = pytesseract.image_to_string(page_img, lang="ita+eng")
+ if t and t.strip():
+ parts.append(t.strip())
+ except Exception:
+ continue
+ return " ".join(parts)
+ except ImportError:
+ logger.warning(
+ "pdf2image o pytesseract non installati: impossibile OCR PDF"
+ )
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore OCR PDF: {e}")
+ return ""
+
+
+def _extract_image_ocr(content: bytes) -> str:
+ """
+ Estrae testo da un file immagine (PNG, JPEG, TIFF, BMP, ecc.) tramite OCR.
+
+ Usa Tesseract con lingua italiana + inglese per massima copertura
+ su documenti italiani.
+ """
+ try:
+ import pytesseract # type: ignore[import]
+ from PIL import Image # type: ignore[import]
+
+ img = Image.open(io.BytesIO(content))
+ # Converti in RGB se necessario (TIFF multi-frame, palette, ecc.)
+ if img.mode not in ("RGB", "L"):
+ img = img.convert("RGB")
+ text = pytesseract.image_to_string(img, lang="ita+eng")
+ return " ".join(text.split())
+ except ImportError:
+ logger.warning(
+ "pytesseract o Pillow non installati: impossibile OCR immagine"
+ )
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore OCR immagine: {e}")
+ return ""
+
+
+def _extract_docx(content: bytes) -> str:
+ """Estrae testo da DOCX/DOC tramite python-docx."""
+ try:
+ import docx # type: ignore[import]
+ doc = docx.Document(io.BytesIO(content))
+ parts = [p.text for p in doc.paragraphs if p.text and p.text.strip()]
+ # Include anche le tabelle
+ for table in doc.tables:
+ for row in table.rows:
+ for cell in row.cells:
+ if cell.text and cell.text.strip():
+ parts.append(cell.text.strip())
+ return " ".join(parts)
+ except ImportError:
+ logger.warning("python-docx non installato: impossibile estrarre testo da DOCX")
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore estrazione DOCX: {e}")
+ return ""
+
+
+def _extract_xlsx(content: bytes) -> str:
+ """Estrae testo da XLSX/XLS tramite openpyxl."""
+ try:
+ import openpyxl # type: ignore[import]
+ wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
+ parts: list[str] = []
+ for ws in wb.worksheets:
+ for row in ws.iter_rows():
+ for cell in row:
+ if cell.value is not None:
+ v = str(cell.value).strip()
+ if v:
+ parts.append(v)
+ return " ".join(parts)
+ except ImportError:
+ logger.warning("openpyxl non installato: impossibile estrarre testo da XLSX")
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore estrazione XLSX: {e}")
+ return ""
+
+
+def _extract_pptx(content: bytes) -> str:
+ """Estrae testo da PPTX/PPT tramite python-pptx."""
+ try:
+ from pptx import Presentation # type: ignore[import]
+ prs = Presentation(io.BytesIO(content))
+ parts: list[str] = []
+ for slide in prs.slides:
+ for shape in slide.shapes:
+ if shape.has_text_frame:
+ for para in shape.text_frame.paragraphs:
+ t = para.text.strip()
+ if t:
+ parts.append(t)
+ return " ".join(parts)
+ except ImportError:
+ logger.warning("python-pptx non installato: impossibile estrarre testo da PPTX")
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore estrazione PPTX: {e}")
+ return ""
+
+
+def _extract_odt(content: bytes) -> str:
+ """Estrae testo da ODT/ODS/ODP tramite odfpy."""
+ try:
+ from odf import opendocument, teletype # type: ignore[import]
+ from odf.text import P # type: ignore[import]
+ doc = opendocument.load(io.BytesIO(content))
+ parts: list[str] = []
+ for el in doc.body.getElementsByType(P):
+ t = teletype.extractText(el).strip()
+ if t:
+ parts.append(t)
+ return " ".join(parts)
+ except ImportError:
+ logger.warning("odfpy non installato: impossibile estrarre testo da ODT")
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore estrazione ODT: {e}")
+ return ""
+
+
+def _extract_rtf(content: bytes) -> str:
+ """Estrae testo da RTF tramite striprtf."""
+ try:
+ from striprtf.striprtf import rtf_to_text # type: ignore[import]
+ raw = content.decode("latin-1", errors="replace")
+ return rtf_to_text(raw)
+ except ImportError:
+ logger.warning("striprtf non installato: impossibile estrarre testo da RTF")
+ return ""
+ except Exception as e:
+ logger.debug(f"Errore estrazione RTF: {e}")
+ return ""
+
+
+def _extract_plain(content: bytes) -> str:
+ """Estrae testo da file di testo puro (txt, csv, xml, html, ecc.)."""
+ try:
+ # Prova UTF-8 prima, poi latin-1 come fallback
+ try:
+ text = content.decode("utf-8")
+ except UnicodeDecodeError:
+ text = content.decode("latin-1", errors="replace")
+ # Per XML/HTML: rimuove i tag
+ if "<" in text and ">" in text:
+ text = re.sub(r"<[^>]+>", " ", text)
+ text = re.sub(r"&[a-zA-Z]+;", " ", text)
+ return " ".join(text.split())
+ except Exception as e:
+ logger.debug(f"Errore estrazione testo plain: {e}")
+ return ""
+
+
+def _extract_eml(content: bytes) -> str:
+ """Estrae testo da un file EML allegato."""
+ try:
+ import email as emaillib
+ msg = emaillib.message_from_bytes(content)
+ parts: list[str] = []
+ subject = msg.get("Subject", "")
+ if subject:
+ parts.append(subject)
+ sender = msg.get("From", "")
+ if sender:
+ parts.append(sender)
+ # Estrae body
+ if msg.is_multipart():
+ for part in msg.walk():
+ ct = part.get_content_type()
+ if ct == "text/plain":
+ try:
+ payload = part.get_payload(decode=True)
+ if payload:
+ charset = part.get_content_charset() or "utf-8"
+ parts.append(payload.decode(charset, errors="replace"))
+ except Exception:
+ pass
+ else:
+ payload = msg.get_payload(decode=True)
+ if payload:
+ charset = msg.get_content_charset() or "utf-8"
+ parts.append(payload.decode(charset, errors="replace")) # type: ignore[arg-type]
+ return " ".join(parts)
+ except Exception as e:
+ logger.debug(f"Errore estrazione EML: {e}")
+ return ""
+
+
+def _extract_p7m(content: bytes, original_filename: str | None = None) -> str:
+ """
+ Estrae testo da un documento con firma digitale CAdES (.p7m).
+
+ Prova a fare l'unwrap del CMS envelope tramite la libreria cryptography
+ (gia' presente nel worker). Se l'unwrap ha successo, determina il formato
+ del documento interno dall'estensione del nome originale (es. fattura.pdf.p7m
+ -> PDF) e applica l'estrattore appropriato.
+ """
+ inner_content: bytes | None = None
+
+ # Metodo 1: cryptography (CMS/PKCS7)
+ try:
+ from cryptography.hazmat.primitives.serialization import pkcs7 # type: ignore[import]
+ # load_pem_pkcs7_certificates / load_der_pkcs7_certificates non espongono il payload
+ # Usiamo il modulo backend direttamente
+ from cryptography.hazmat.backends import default_backend
+ from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
+ from cryptography.x509 import load_der_x509_certificate # noqa: F401
+
+ # Prova parsing DER diretto della struttura CMS ContentInfo
+ # La struttura ASN.1 di SignedData contiene encapContentInfo -> eContent
+ from cryptography.hazmat.bindings._rust import ( # type: ignore[import]
+ x509 as rust_x509,
+ )
+ _ = rust_x509 # solo per verificare import
+ except Exception:
+ pass
+
+ # Metodo piu' semplice: parsing ASN.1 manuale per estrarre eContent
+ # La struttura DER di CMS SignedData:
+ # SEQUENCE {
+ # OID (signedData)
+ # [0] EXPLICIT SEQUENCE {
+ # INTEGER (version)
+ # SET (digestAlgorithms)
+ # SEQUENCE (encapContentInfo) {
+ # OID (contentType = data)
+ # [0] EXPLICIT OCTET STRING (eContent) <- questo e' il contenuto originale
+ # }
+ # ...
+ # }
+ # }
+ try:
+ inner_content = _unwrap_p7m_asn1(content)
+ except Exception as e:
+ logger.debug(f"Unwrap P7M ASN1 fallito: {e}")
+
+ if not inner_content:
+ logger.debug("Impossibile estrarre contenuto dal file .p7m")
+ return ""
+
+ # Determina l'estensione interna dal nome file originale
+ # es. "fattura.pdf.p7m" -> inner ext = "pdf"
+ inner_ext = ""
+ if original_filename:
+ fn = original_filename.lower()
+ if fn.endswith(".p7m"):
+ fn = fn[:-4] # rimuove .p7m
+ idx = fn.rfind(".")
+ if idx >= 0:
+ inner_ext = fn[idx + 1:]
+
+ extractor = _EXTRACTORS.get(inner_ext)
+ if extractor:
+ logger.debug(f"P7M: estrazione interna come {inner_ext!r}")
+ return extractor(inner_content)
+
+ # Fallback: prova a riconoscere il formato dall'header del contenuto
+ if inner_content[:4] == b"%PDF":
+ return _extract_pdf(inner_content)
+ if inner_content[:2] in (b"PK",): # ZIP-based (docx, xlsx, pptx, odt)
+ # Prova nell'ordine piu' comune
+ for fn in (_extract_docx, _extract_xlsx, _extract_pptx, _extract_odt):
+ result = fn(inner_content)
+ if result.strip():
+ return result
+ # Ultimo tentativo: plain text
+ return _extract_plain(inner_content)
+
+
+def _unwrap_p7m_asn1(data: bytes) -> bytes | None:
+ """
+ Parsing ASN.1 DER minimale per estrarre eContent da una struttura CMS SignedData.
+ Non verifica la firma: serve solo per l'estrazione del testo.
+ """
+ pos = 0
+ length = len(data)
+
+ def read_tag_length(buf: bytes, offset: int) -> tuple[int, int, int]:
+ """Ritorna (tag, length, new_offset)."""
+ tag = buf[offset]
+ offset += 1
+ lb = buf[offset]
+ offset += 1
+ if lb & 0x80:
+ num_bytes = lb & 0x7F
+ ln = int.from_bytes(buf[offset:offset + num_bytes], "big")
+ offset += num_bytes
+ else:
+ ln = lb
+ return tag, ln, offset
+
+ # outer SEQUENCE
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x30:
+ return None
+
+ # OID (contentType = signedData)
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x06:
+ return None
+ pos += ln # skip OID value
+
+ # [0] EXPLICIT
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0xA0:
+ return None
+
+ # SEQUENCE (SignedData)
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x30:
+ return None
+
+ # INTEGER (version)
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x02:
+ return None
+ pos += ln
+
+ # SET (digestAlgorithms)
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x31:
+ return None
+ pos += ln
+
+ # SEQUENCE (encapContentInfo)
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x30:
+ return None
+
+ # OID (contentType dentro encapContentInfo)
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x06:
+ return None
+ pos += ln
+
+ # [0] EXPLICIT (eContent, opzionale)
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0xA0:
+ return None
+
+ # OCTET STRING con il contenuto originale
+ tag, ln, pos = read_tag_length(data, pos)
+ if tag != 0x04:
+ return None
+ return data[pos: pos + ln]
+
+
+# ─── Mapping formato -> estrattore ────────────────────────────────────────────
+
+_EXTRACTORS: dict[str, object] = {
+ # Documenti Office
+ "pdf": _extract_pdf,
+ "docx": _extract_docx,
+ "doc": _extract_docx,
+ "xlsx": _extract_xlsx,
+ "xls": _extract_xlsx,
+ "pptx": _extract_pptx,
+ "ppt": _extract_pptx,
+ # LibreOffice
+ "odt": _extract_odt,
+ "ods": _extract_odt,
+ "odp": _extract_odt,
+ # Testo
+ "txt": _extract_plain,
+ "csv": _extract_plain,
+ "xml": _extract_plain,
+ "html": _extract_plain,
+ "htm": _extract_plain,
+ "json": _extract_plain,
+ # RTF
+ "rtf": _extract_rtf,
+ # Email
+ "eml": _extract_eml,
+ "msg": _extract_eml,
+ # Firma digitale CAdES
+ "p7m": _extract_p7m,
+ # Immagini (OCR)
+ "png": _extract_image_ocr,
+ "jpg": _extract_image_ocr,
+ "jpeg": _extract_image_ocr,
+ "tiff": _extract_image_ocr,
+ "tif": _extract_image_ocr,
+ "bmp": _extract_image_ocr,
+ "gif": _extract_image_ocr,
+ "webp": _extract_image_ocr,
+}
+
+# Mapping content-type -> estensione normalizzata (per fallback quando il filename manca)
+_CONTENT_TYPE_MAP: dict[str, str] = {
+ "application/pdf": "pdf",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
+ "application/msword": "doc",
+ "application/vnd.ms-word": "doc",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
+ "application/vnd.ms-excel": "xls",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
+ "application/vnd.ms-powerpoint": "ppt",
+ "application/vnd.oasis.opendocument.text": "odt",
+ "application/vnd.oasis.opendocument.spreadsheet": "ods",
+ "application/vnd.oasis.opendocument.presentation": "odp",
+ "application/rtf": "rtf",
+ "text/rtf": "rtf",
+ "text/plain": "txt",
+ "text/csv": "csv",
+ "text/xml": "xml",
+ "application/xml": "xml",
+ "text/html": "html",
+ "message/rfc822": "eml",
+ "application/pkcs7-mime": "p7m",
+ "application/x-pkcs7-mime": "p7m",
+ # Immagini (OCR)
+ "image/png": "png",
+ "image/jpeg": "jpeg",
+ "image/jpg": "jpeg",
+ "image/tiff": "tiff",
+ "image/bmp": "bmp",
+ "image/gif": "gif",
+ "image/webp": "webp",
+}
+
+
+# ─── Job principale ───────────────────────────────────────────────────────────
+
+async def index_message(
+ message_id: uuid.UUID,
+ db: AsyncSession,
+) -> None:
+ """
+ Indicizza un messaggio per la ricerca full-text.
+
+ Non solleva eccezioni: tutti gli errori vengono loggati ma non propagati,
+ per non interrompere il flusso di sincronizzazione.
+ """
+ try:
+ await _do_index_message(message_id, db)
+ except Exception as e:
+ logger.error(
+ f"Errore indicizzazione messaggio {message_id}: {e}",
+ exc_info=True,
+ )
+
+
+async def _do_index_message(
+ message_id: uuid.UUID,
+ db: AsyncSession,
+) -> None:
+ """Logica interna di indicizzazione (puo' sollevare eccezioni)."""
+ from app.config import get_settings
+ from app.models import Attachment, Message
+
+ settings = get_settings()
+
+ # ── Carica il messaggio ───────────────────────────────────────────────────
+ msg_result = await db.execute(
+ select(Message).where(Message.id == message_id)
+ )
+ message = msg_result.scalar_one_or_none()
+ if not message:
+ logger.warning(f"index_message: messaggio {message_id} non trovato in DB")
+ return
+
+ # ── Carica gli allegati ───────────────────────────────────────────────────
+ att_result = await db.execute(
+ select(Attachment).where(Attachment.message_id == message_id)
+ )
+ attachments = list(att_result.scalars().all())
+
+ if not attachments:
+ logger.debug(f"Messaggio {message_id}: nessun allegato, skip indicizzazione allegati")
+ return
+
+ # ── Crea client MinIO ─────────────────────────────────────────────────────
+ try:
+ from miniopy_async import Minio # type: ignore[import]
+
+ minio = Minio(
+ endpoint=settings.minio_endpoint,
+ access_key=settings.minio_access_key,
+ secret_key=settings.minio_secret_key,
+ secure=settings.minio_use_ssl,
+ )
+ except Exception as e:
+ logger.warning(f"Impossibile creare client MinIO per indicizzazione {message_id}: {e}")
+ return
+
+ bucket = settings.minio_bucket
+ attachment_texts: list[str] = []
+ indexed_count = 0
+
+ for att in attachments:
+ # Se gia' indicizzato, usa il testo cached
+ if att.extracted_text is not None:
+ attachment_texts.append(att.extracted_text)
+ continue
+
+ # Controlla se il formato e' supportato
+ extractor = _resolve_extractor(att.content_type, att.filename)
+ if extractor is None:
+ logger.debug(
+ f"Formato non supportato per indicizzazione: "
+ f"{att.filename!r} ({att.content_type!r})"
+ )
+ continue
+
+ # Scarica da MinIO
+ try:
+ response = await minio.get_object(bucket, att.storage_path)
+ content = await response.content.read()
+ response.close()
+ except Exception as e:
+ logger.warning(
+ f"Impossibile scaricare allegato {att.id} "
+ f"({att.filename!r}) da MinIO: {e}"
+ )
+ continue
+
+ # Estrai testo - per p7m passa anche il filename originale
+ try:
+ e = _ext(att.filename)
+ if e == "p7m":
+ extracted = _extract_p7m(content, att.filename)
+ else:
+ extracted = extractor(content) # type: ignore[operator]
+ except Exception as ex:
+ logger.debug(f"Errore estrazione {att.filename!r}: {ex}")
+ continue
+
+ if not extracted or not extracted.strip():
+ logger.debug(f"Nessun testo estratto da {att.filename!r}")
+ continue
+
+ # Limita la dimensione e salva sull'ORM object (col. mappata)
+ att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN]
+ attachment_texts.append(att.extracted_text)
+ indexed_count += 1
+ logger.debug(
+ f"Testo estratto da {att.filename!r}: "
+ f"{len(att.extracted_text)} caratteri"
+ )
+
+ # ── Aggiorna search_vector includendo il testo degli allegati ─────────────
+ if attachment_texts:
+ combined = " ".join(attachment_texts)[:MAX_COMBINED_TEXT_LEN]
+
+ await db.execute(
+ text("""
+ UPDATE messages
+ SET search_vector =
+ setweight(to_tsvector('italian', coalesce(subject, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') ||
+ setweight(to_tsvector('simple',
+ coalesce(array_to_string(to_addresses, ' '), '')), 'B') ||
+ setweight(to_tsvector('italian', coalesce(body_text, '')), 'C') ||
+ setweight(to_tsvector('italian', :att_text), 'D')
+ WHERE id = :message_id
+ """),
+ {"att_text": combined, "message_id": str(message_id)},
+ )
+
+ await db.flush()
+
+ logger.info(
+ f"Indicizzazione completata: messaggio {message_id}, "
+ f"{indexed_count} allegati indicizzati su {len(attachments)} totali"
+ )
+ else:
+ logger.debug(
+ f"Messaggio {message_id}: nessun allegato con testo estraibile"
+ )
diff --git a/worker/app/main.py b/worker/app/main.py
index 75a9ff9..4b8289f 100644
--- a/worker/app/main.py
+++ b/worker/app/main.py
@@ -24,6 +24,8 @@ from arq.connections import RedisSettings
from app.config import get_settings
from app.imap.pool import MailboxPool
+from app.jobs.apply_routing_rules import apply_routing_rules
+from app.jobs.dispatch_notification import dispatch_notification
from app.jobs.send_pec import send_pec
from app.jobs.sync_mailbox import sync_mailbox
from app.smtp.receipt_watcher import watch_receipt
@@ -132,7 +134,7 @@ class WorkerSettings:
"""Configurazione del worker arq."""
# Funzioni/job registrati
- functions = [sync_mailbox, send_pec, watch_receipt, health_check]
+ functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, apply_routing_rules, health_check]
# Callbacks lifecycle
on_startup = on_startup
diff --git a/worker/app/models.py b/worker/app/models.py
index afed007..edf4c7c 100644
--- a/worker/app/models.py
+++ b/worker/app/models.py
@@ -22,7 +22,7 @@ from sqlalchemy import (
ARRAY, BigInteger, Boolean, DateTime, Enum, ForeignKey,
Integer, String, Text, func,
)
-from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -194,9 +194,135 @@ class Attachment(Base):
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ # Testo estratto dall'indicizzatore full-text per la ricerca
+ extracted_text: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# Relazione inversa verso Message
message: Mapped["Message"] = relationship("Message", back_populates="attachments")
+
+
+# ─── Modelli Notifiche ────────────────────────────────────────────────────────
+
+NotifChannelType = Enum(
+ "webhook", "email", "telegram", "whatsapp",
+ name="notification_channel_type",
+ create_type=False,
+)
+
+NotifStatus = Enum(
+ "pending", "sent", "failed", "skipped",
+ name="notification_status",
+ create_type=False,
+)
+
+
+class NotificationChannel(Base):
+ """
+ Canale di notifica configurato da un tenant.
+ Corrisponde alla tabella `notification_channels` nel DB.
+ """
+
+ __tablename__ = "notification_channels"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ channel_type: Mapped[str] = mapped_column(NotifChannelType, nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+ config_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
+ consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ circuit_open_until: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+ rules: Mapped[list["NotificationRule"]] = relationship(
+ "NotificationRule", back_populates="channel", lazy="select"
+ )
+ logs: Mapped[list["NotificationLog"]] = relationship(
+ "NotificationLog", back_populates="channel", lazy="select"
+ )
+
+
+class NotificationRule(Base):
+ """
+ Regola evento PEC -> canale di notifica.
+ Corrisponde alla tabella `notification_rules` nel DB.
+ """
+
+ __tablename__ = "notification_rules"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
+ channel_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("notification_channels.id", ondelete="CASCADE"),
+ nullable=False,
+ )
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ event_type: Mapped[str] = mapped_column(String(100), nullable=False)
+ filter: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+
+ channel: Mapped["NotificationChannel"] = relationship(
+ "NotificationChannel", back_populates="rules"
+ )
+
+
+class NotificationLog(Base):
+ """
+ Log di ogni tentativo di notifica con retry e circuit breaker.
+ Corrisponde alla tabella `notification_log` nel DB.
+ """
+
+ __tablename__ = "notification_log"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
+ channel_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("notification_channels.id", ondelete="CASCADE"),
+ nullable=False,
+ )
+ rule_id: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("notification_rules.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+ event_type: Mapped[str] = mapped_column(String(100), nullable=False)
+ event_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+ status: Mapped[str] = mapped_column(NotifStatus, nullable=False, default="pending")
+ attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
+ next_retry_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
+ http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+
+ channel: Mapped["NotificationChannel"] = relationship(
+ "NotificationChannel", back_populates="logs"
+ )
diff --git a/worker/app/notifications/__init__.py b/worker/app/notifications/__init__.py
new file mode 100644
index 0000000..14baf19
--- /dev/null
+++ b/worker/app/notifications/__init__.py
@@ -0,0 +1 @@
+# Senders notifiche multi-canale per il worker
diff --git a/worker/app/notifications/email_smtp.py b/worker/app/notifications/email_smtp.py
new file mode 100644
index 0000000..315043b
--- /dev/null
+++ b/worker/app/notifications/email_smtp.py
@@ -0,0 +1,76 @@
+"""
+Email SMTP sender (worker) – invio notifiche via aiosmtplib.
+
+Copia del sender backend: i due container sono separati.
+"""
+
+from datetime import datetime
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+import aiosmtplib
+
+DEFAULT_TIMEOUT = 15.0
+
+
+class EmailSMTPError(Exception):
+ def __init__(self, message: str, smtp_code: int | None = None):
+ super().__init__(message)
+ self.smtp_code = smtp_code
+
+
+async def send_email_notification(
+ smtp_host: str,
+ smtp_port: int,
+ smtp_user: str,
+ smtp_password: str,
+ from_email: str,
+ to_email: str,
+ subject: str,
+ body_text: str,
+ body_html: str | None = None,
+ from_name: str = "PEChub Notifiche",
+ use_tls: bool = True,
+ use_starttls: bool = False,
+ timeout: float = DEFAULT_TIMEOUT,
+) -> None:
+ """
+ Invia un'email di notifica via SMTP.
+
+ Raises:
+ EmailSMTPError: in caso di errori di autenticazione, connessione o invio
+ """
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = subject
+ msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
+ msg["To"] = to_email
+ msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
+ msg["X-Mailer"] = "PEChub/1.0"
+
+ msg.attach(MIMEText(body_text, "plain", "utf-8"))
+ if body_html:
+ msg.attach(MIMEText(body_html, "html", "utf-8"))
+
+ try:
+ await aiosmtplib.send(
+ msg,
+ hostname=smtp_host,
+ port=smtp_port,
+ username=smtp_user,
+ password=smtp_password,
+ use_tls=use_tls,
+ start_tls=use_starttls,
+ timeout=timeout,
+ )
+ except aiosmtplib.SMTPAuthenticationError as exc:
+ raise EmailSMTPError(
+ f"Autenticazione SMTP fallita: {exc}", smtp_code=535
+ ) from exc
+ except aiosmtplib.SMTPConnectError as exc:
+ raise EmailSMTPError(
+ f"Connessione SMTP fallita a {smtp_host}:{smtp_port}: {exc}"
+ ) from exc
+ except aiosmtplib.SMTPException as exc:
+ raise EmailSMTPError(f"Errore SMTP: {exc}") from exc
+ except Exception as exc:
+ raise EmailSMTPError(f"Errore invio email: {exc}") from exc
diff --git a/worker/app/notifications/telegram.py b/worker/app/notifications/telegram.py
new file mode 100644
index 0000000..b9d203d
--- /dev/null
+++ b/worker/app/notifications/telegram.py
@@ -0,0 +1,69 @@
+"""
+Telegram Bot API – invio messaggi via sendMessage (worker).
+
+Copia del sender backend: i due container sono separati e non
+possono condividere package, quindi il codice e' duplicato.
+"""
+
+import httpx
+
+TELEGRAM_API_BASE = "https://api.telegram.org"
+DEFAULT_TIMEOUT = 10.0
+
+
+class TelegramError(Exception):
+ def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
+ super().__init__(message)
+ self.http_status = http_status
+ self.api_code = api_code
+
+
+async def send_message(
+ bot_token: str,
+ chat_id: str,
+ text: str,
+ parse_mode: str = "HTML",
+ disable_web_page_preview: bool = True,
+ timeout: float = DEFAULT_TIMEOUT,
+) -> dict:
+ """
+ Invia un messaggio a un canale/gruppo/utente Telegram.
+
+ Returns:
+ dict con il risultato della API Telegram (result.message_id, ecc.)
+
+ Raises:
+ TelegramError: in caso di errore HTTP o risposta API non-ok
+ """
+ url = f"{TELEGRAM_API_BASE}/bot{bot_token}/sendMessage"
+ payload: dict = {"chat_id": chat_id, "text": text}
+ if parse_mode:
+ payload["parse_mode"] = parse_mode
+ if disable_web_page_preview:
+ payload["link_preview_options"] = {"is_disabled": True}
+
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ try:
+ response = await client.post(url, json=payload)
+ except httpx.TimeoutException as exc:
+ raise TelegramError(f"Timeout Telegram ({timeout}s)") from exc
+ except httpx.RequestError as exc:
+ raise TelegramError(f"Errore di rete Telegram: {exc}") from exc
+
+ if response.status_code != 200:
+ raise TelegramError(
+ f"Telegram API HTTP {response.status_code}: {response.text[:200]}",
+ http_status=response.status_code,
+ )
+
+ data = response.json()
+ if not data.get("ok"):
+ api_code = data.get("error_code")
+ description = data.get("description", "Errore sconosciuto")
+ raise TelegramError(
+ f"Telegram API error {api_code}: {description}",
+ http_status=response.status_code,
+ api_code=api_code,
+ )
+
+ return data.get("result", {})
diff --git a/worker/app/notifications/webhook.py b/worker/app/notifications/webhook.py
new file mode 100644
index 0000000..fb74cac
--- /dev/null
+++ b/worker/app/notifications/webhook.py
@@ -0,0 +1,74 @@
+"""
+Webhook sender (worker) – POST HTTP con firma HMAC-SHA256.
+
+Copia del sender backend: i due container sono separati.
+"""
+
+import hashlib
+import hmac
+import json
+import uuid as uuid_mod
+
+import httpx
+
+DEFAULT_TIMEOUT = 10.0
+
+
+class WebhookError(Exception):
+ def __init__(self, message: str, http_status: int | None = None):
+ super().__init__(message)
+ self.http_status = http_status
+
+
+async def send_webhook(
+ url: str,
+ payload: dict,
+ event_type: str = "new_message",
+ webhook_secret: str | None = None,
+ timeout: float = DEFAULT_TIMEOUT,
+) -> dict:
+ """
+ Invia un payload JSON a un webhook URL.
+
+ Returns:
+ dict con http_status, delivery_id
+
+ Raises:
+ WebhookError: in caso di timeout, errore di rete o HTTP >= 400
+ """
+ body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
+ delivery_id = str(uuid_mod.uuid4())
+
+ headers = {
+ "Content-Type": "application/json",
+ "X-PEChub-Event": event_type,
+ "X-Delivery": delivery_id,
+ "User-Agent": "PEChub-Webhook/1.0",
+ }
+
+ if webhook_secret:
+ sig = hmac.new(
+ webhook_secret.encode("utf-8"),
+ body,
+ hashlib.sha256,
+ ).hexdigest()
+ headers["X-Hub-Signature-256"] = f"sha256={sig}"
+
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ try:
+ response = await client.post(url, content=body, headers=headers)
+ except httpx.TimeoutException as exc:
+ raise WebhookError(f"Timeout webhook dopo {timeout}s") from exc
+ except httpx.RequestError as exc:
+ raise WebhookError(f"Errore di rete webhook: {exc}") from exc
+
+ if response.status_code >= 400:
+ raise WebhookError(
+ f"Webhook HTTP {response.status_code}: {response.text[:200]}",
+ http_status=response.status_code,
+ )
+
+ return {
+ "http_status": response.status_code,
+ "delivery_id": delivery_id,
+ }
diff --git a/worker/app/notifications/whatsapp.py b/worker/app/notifications/whatsapp.py
new file mode 100644
index 0000000..96ff266
--- /dev/null
+++ b/worker/app/notifications/whatsapp.py
@@ -0,0 +1,73 @@
+"""
+WhatsApp sender (worker) – Meta Cloud API v18.
+
+Copia del sender backend: i due container sono separati.
+"""
+
+import httpx
+
+META_GRAPH_API_URL = "https://graph.facebook.com/v18.0"
+DEFAULT_TIMEOUT = 10.0
+
+
+class WhatsAppError(Exception):
+ def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
+ super().__init__(message)
+ self.http_status = http_status
+ self.api_code = api_code
+
+
+async def send_whatsapp_message(
+ phone_number_id: str,
+ to_phone: str,
+ text: str,
+ access_token: str,
+ timeout: float = DEFAULT_TIMEOUT,
+) -> dict:
+ """
+ Invia un messaggio di testo WhatsApp via Meta Cloud API.
+
+ Raises:
+ WhatsAppError: in caso di errore HTTP o risposta API non-ok
+ """
+ url = f"{META_GRAPH_API_URL}/{phone_number_id}/messages"
+ payload = {
+ "messaging_product": "whatsapp",
+ "to": to_phone.replace(" ", "").replace("-", ""),
+ "type": "text",
+ "text": {"body": text},
+ }
+ headers = {
+ "Authorization": f"Bearer {access_token}",
+ "Content-Type": "application/json",
+ }
+
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ try:
+ response = await client.post(url, json=payload, headers=headers)
+ except httpx.TimeoutException as exc:
+ raise WhatsAppError(f"Timeout WhatsApp API dopo {timeout}s") from exc
+ except httpx.RequestError as exc:
+ raise WhatsAppError(f"Errore di rete WhatsApp: {exc}") from exc
+
+ if response.status_code == 401:
+ raise WhatsAppError("Token Meta non valido o scaduto", http_status=401)
+
+ if response.status_code >= 400:
+ try:
+ err_data = response.json()
+ err_msg = err_data.get("error", {}).get("message", response.text[:200])
+ err_code = err_data.get("error", {}).get("code")
+ except Exception:
+ err_msg = response.text[:200]
+ err_code = None
+ raise WhatsAppError(
+ f"Meta API errore HTTP {response.status_code}: {err_msg}",
+ http_status=response.status_code,
+ api_code=err_code,
+ )
+
+ data = response.json()
+ messages = data.get("messages", [])
+ message_id = messages[0].get("id") if messages else None
+ return {"message_id": message_id, "http_status": response.status_code}
diff --git a/worker/app/parsers/eml_parser.py b/worker/app/parsers/eml_parser.py
index 8047878..43b5c22 100644
--- a/worker/app/parsers/eml_parser.py
+++ b/worker/app/parsers/eml_parser.py
@@ -116,7 +116,7 @@ def parse_date(date_str: str | None) -> datetime | None:
# ─── Parser principale ────────────────────────────────────────────────────────
-def parse_eml(raw_bytes: bytes) -> ParsedEmail:
+def parse_eml(raw_bytes: bytes, is_receipt: bool = False) -> ParsedEmail:
"""
Parsing completo di un raw EML.
@@ -126,7 +126,11 @@ def parse_eml(raw_bytes: bytes) -> ParsedEmail:
- Allegati: tutti i parti con filename, inclusi message/rfc822
Args:
- raw_bytes: byte del messaggio EML grezzo
+ raw_bytes: byte del messaggio EML grezzo
+ is_receipt: True se il messaggio e' una ricevuta PEC (accettazione,
+ avvenuta_consegna, ecc.). In questo caso il body_text/html
+ esterno (testo della ricevuta) non viene sovrascritto con
+ il contenuto del messaggio annidato in postacert.eml.
Returns:
ParsedEmail con tutti i campi estratti (fields None/[] se non presenti)
@@ -153,7 +157,7 @@ def parse_eml(raw_bytes: bytes) -> ParsedEmail:
# ── Body e allegati ───────────────────────────────────────────────────────
if msg.is_multipart():
- _walk_parts(msg, result)
+ _walk_parts(msg, result, is_receipt=is_receipt)
else:
_extract_single_part_body(msg, result)
@@ -208,7 +212,7 @@ def _get_filename(part: email.message.Message) -> str | None:
return None
-def _walk_parts(msg: email.message.Message, result: ParsedEmail) -> None:
+def _walk_parts(msg: email.message.Message, result: ParsedEmail, is_receipt: bool = False) -> None:
"""
Naviga ricorsivamente tutti i part MIME del messaggio.
@@ -230,7 +234,7 @@ def _walk_parts(msg: email.message.Message, result: ParsedEmail) -> None:
# ── EML-in-EML (message/rfc822) ───────────────────────────────────────
if ct == "message/rfc822":
- _extract_eml_in_eml(part, filename, result)
+ _extract_eml_in_eml(part, filename, result, is_receipt=is_receipt)
continue
# ── Allegato esplicito (Content-Disposition: attachment) ──────────────
@@ -292,12 +296,16 @@ def _extract_eml_in_eml(
part: email.message.Message,
filename: str | None,
result: ParsedEmail,
+ is_receipt: bool = False,
) -> None:
"""
Estrae il messaggio EML annidato in un part message/rfc822.
- Per postacert.eml (busta PEC in arrivo): ricorre dentro per estrarre
+ Per postacert.eml in messaggi posta_certificata: ricorre dentro per estrarre
gli allegati utente e il corpo del messaggio originale del mittente.
+
+ Per le ricevute (is_receipt=True): estrae solo gli allegati utente senza
+ sovrascrivere il body gia' impostato (che e' il testo della ricevuta stessa).
"""
try:
payload = part.get_payload()
@@ -305,7 +313,7 @@ def _extract_eml_in_eml(
inner_bytes: bytes | None = None
if isinstance(payload, list) and payload:
- # Forma canonica: payload è lista di Message
+ # Forma canonica: payload e' lista di Message
inner_msg = payload[0]
if isinstance(inner_msg, email.message.Message):
inner_bytes = inner_msg.as_bytes()
@@ -330,19 +338,22 @@ def _extract_eml_in_eml(
)
result.attachments.append(att)
- # Per postacert.eml: ricorre dentro per trovare allegati utente e corpo originale
+ # Per postacert.eml: ricorre dentro per trovare allegati utente
if is_system and eff_filename.lower() == "postacert.eml":
inner_parsed = parse_eml(inner_bytes)
# Allegati non-sistema del messaggio originale del mittente
for inner_att in inner_parsed.attachments:
if not inner_att.is_pec_system:
result.attachments.append(inner_att)
- # Corpo del messaggio originale (più utile del testo della busta PEC)
- if inner_parsed.body_html:
- result.body_html = inner_parsed.body_html
- result.body_text = inner_parsed.body_text
- elif inner_parsed.body_text:
- result.body_text = inner_parsed.body_text
+ # Sovrascrive il corpo SOLO per messaggi posta_certificata (non ricevute).
+ # Per le ricevute il body esterno e' gia' il testo corretto della ricevuta;
+ # postacert.eml contiene il messaggio originale inviato che non va mostrato.
+ if not is_receipt:
+ if inner_parsed.body_html:
+ result.body_html = inner_parsed.body_html
+ result.body_text = inner_parsed.body_text
+ elif inner_parsed.body_text:
+ result.body_text = inner_parsed.body_text
except Exception as exc:
logger.warning(f"Errore estrazione EML-in-EML: {exc}")
diff --git a/worker/pyproject.toml b/worker/pyproject.toml
index c31231c..e63197c 100644
--- a/worker/pyproject.toml
+++ b/worker/pyproject.toml
@@ -41,6 +41,19 @@ dependencies = [
# Utilities
"python-dotenv>=1.0.0",
"email-validator>=2.2.0",
+
+ # Full-text search: estrazione testo da allegati
+ "pypdf>=4.0.0",
+ "python-docx>=1.1.0",
+ "openpyxl>=3.1.0",
+ "python-pptx>=1.0.0",
+ "odfpy>=1.4.1",
+ "striprtf>=0.0.26",
+
+ # OCR per allegati image-only (immagini dirette e PDF scansionati)
+ "pytesseract>=0.3.13",
+ "pdf2image>=1.17.0",
+ "Pillow>=11.0.0",
]
[project.optional-dependencies]
diff --git a/worker/scripts/fix_receipt_body.py b/worker/scripts/fix_receipt_body.py
new file mode 100644
index 0000000..8b40c3b
--- /dev/null
+++ b/worker/scripts/fix_receipt_body.py
@@ -0,0 +1,129 @@
+"""
+Script one-shot: corregge il body_text/body_html delle ricevute PEC gia' in DB.
+
+Problema: il parser EML sovrascriveva il body delle ricevute con il contenuto
+di postacert.eml (messaggio originale inviato), invece di mostrare il testo
+della ricevuta stessa.
+
+Questo script:
+1. Trova tutti i messaggi in DB con pec_type di tipo ricevuta
+2. Scarica l'EML grezzo da MinIO (raw_eml_path)
+3. Lo ri-parsa con is_receipt=True (parser corretto)
+4. Aggiorna body_text e body_html nel DB
+
+Uso:
+ cd /opt/pechub
+ docker compose exec pechub-worker-1 python /app/scripts/fix_receipt_body.py
+"""
+
+import asyncio
+import logging
+import sys
+from datetime import UTC, datetime
+
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+from sqlalchemy.orm import sessionmaker
+
+# Aggiungi il path dell'app
+sys.path.insert(0, "/app")
+
+from app.config import get_settings
+from app.models import Message
+from app.parsers.eml_parser import parse_eml
+from app.storage.minio_client import download_attachment
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(message)s",
+)
+logger = logging.getLogger(__name__)
+
+# Tipi di ricevuta che potrebbero avere il body sbagliato
+RECEIPT_TYPES = {
+ "accettazione",
+ "non_accettazione",
+ "presa_in_carico",
+ "avvenuta_consegna",
+ "mancata_consegna",
+ "errore_consegna",
+ "preavviso_mancata_consegna",
+ "rilevazione_virus",
+}
+
+
+async def fix_receipt_bodies() -> None:
+ settings = get_settings()
+
+ engine = create_async_engine(settings.database_url, echo=False)
+ async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+ async with async_session() as db:
+ # Trova tutti i messaggi ricevuta con raw_eml_path
+ result = await db.execute(
+ select(Message).where(
+ Message.pec_type.in_(RECEIPT_TYPES),
+ Message.raw_eml_path.is_not(None),
+ ).order_by(Message.created_at)
+ )
+ messages = result.scalars().all()
+
+ logger.info(f"Trovate {len(messages)} ricevute da verificare")
+
+ fixed = 0
+ skipped = 0
+ errors = 0
+
+ for msg in messages:
+ try:
+ # Scarica EML grezzo da MinIO (download_attachment funziona per qualsiasi path)
+ raw_eml = await download_attachment(msg.raw_eml_path)
+ if not raw_eml:
+ logger.warning(f"EML non trovato su MinIO per messaggio {msg.id} (path={msg.raw_eml_path!r})")
+ skipped += 1
+ continue
+
+ # Re-parsing con is_receipt=True (parser corretto)
+ parsed = parse_eml(raw_eml, is_receipt=True)
+
+ # Controlla se il body e' cambiato
+ new_body_text = parsed.body_text
+ new_body_html = parsed.body_html
+
+ if new_body_text == msg.body_text and new_body_html == msg.body_html:
+ logger.debug(f"Messaggio {msg.id} ({msg.pec_type}): body invariato, skip")
+ skipped += 1
+ continue
+
+ # Aggiorna nel DB
+ msg.body_text = new_body_text
+ msg.body_html = new_body_html
+ msg.updated_at = datetime.now(UTC)
+
+ logger.info(
+ f"Fixato: id={msg.id} pec_type={msg.pec_type!r} subject={msg.subject!r} "
+ f"body_text_len={len(new_body_text or '')}"
+ )
+ fixed += 1
+
+ except Exception as e:
+ logger.error(f"Errore su messaggio {msg.id}: {e}", exc_info=True)
+ errors += 1
+ continue
+
+ if fixed > 0:
+ await db.commit()
+ logger.info(f"Commit eseguito: {fixed} messaggi aggiornati")
+ else:
+ logger.info("Nessun messaggio da aggiornare")
+
+ logger.info(
+ f"Completato: fixed={fixed} skipped={skipped} errors={errors} "
+ f"totale={len(messages)}"
+ )
+
+ await engine.dispose()
+
+
+if __name__ == "__main__":
+ asyncio.run(fix_receipt_bodies())