This commit is contained in:
2026-04-07 11:32:05 +02:00
103 changed files with 14789 additions and 555 deletions
+1
View File
@@ -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
-38
View File
@@ -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
+113
View File
@@ -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'.
+1 -1
View File
@@ -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
+4
View File
@@ -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
@@ -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')
@@ -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')
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
+65
View File
@@ -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,
)
+21
View File
@@ -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"),
)
+132
View File
@@ -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)
+152
View File
@@ -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,
)
+102 -3
View File
@@ -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}",
)
+535 -72
View File
@@ -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"<tr><td>{label}</td><td>{date_str}</td></tr>"
if receipt_rows:
receipts_html = f"""
<section>
<h3>Tracciamento invio</h3>
<table>
<thead><tr><th>Tipo ricevuta</th><th>Data</th></tr></thead>
<tbody>{receipt_rows}</tbody>
</table>
</section>"""
att_rows = ""
for att in attachments:
size_str = f"{att.size_bytes:,} byte" if att.size_bytes else ""
att_rows += f"<li>{att.filename} ({att.content_type or ''}) {size_str}</li>"
att_html = f"<section><h3>Allegati ({len(attachments)})</h3><ul>{att_rows}</ul></section>" 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"<div class='body'>{message.body_html}</div>"
elif message.body_text:
body_html = f"<pre class='body'>{message.body_text}</pre>"
deadline_html = ""
if message.deadline_at:
dl_str = message.deadline_at.strftime("%d/%m/%Y %H:%M")
deadline_html = f"<p class='deadline'><strong>Scadenza:</strong> {dl_str}</p>"
if message.deadline_note:
deadline_html += f"<p><em>Nota scadenza: {message.deadline_note}</em></p>"
html = f"""<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>PEC - {message.subject or '(nessun oggetto)'}</title>
<style>
body {{ font-family: Arial, sans-serif; font-size: 12pt; margin: 2cm; color: #000; }}
h1 {{ font-size: 16pt; border-bottom: 2px solid #333; padding-bottom: 8px; }}
h3 {{ font-size: 13pt; margin-top: 20px; border-bottom: 1px solid #ccc; }}
.meta {{ background: #f5f5f5; border: 1px solid #ddd; padding: 12px; margin-bottom: 16px; }}
.meta p {{ margin: 4px 0; }}
.body {{ border: 1px solid #ddd; padding: 12px; white-space: pre-wrap; word-wrap: break-word; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; }}
th {{ background: #eee; }}
ul {{ padding-left: 20px; }}
.deadline {{ color: #c00; font-weight: bold; }}
@media print {{ body {{ margin: 1cm; }} }}
</style>
</head>
<body>
<h1>{message.subject or '(nessun oggetto)'}</h1>
<div class="meta">
<p><strong>{from_label}:</strong> {from_val}</p>
{'<p><strong>Da:</strong> ' + message.from_address + '</p>' if message.direction == "outbound" and message.from_address else ''}
{'<p><strong>A:</strong> ' + ', '.join(message.to_addresses or []) + '</p>' if message.direction == "inbound" and message.to_addresses else ''}
{'<p><strong>Cc:</strong> ' + ', '.join(message.cc_addresses or []) + '</p>' if message.cc_addresses else ''}
<p><strong>Data:</strong> {date_val}</p>
<p><strong>Stato:</strong> {message.state}</p>
<p><strong>Tipo:</strong> {message.pec_type}</p>
</div>
{deadline_html}
{body_html}
{att_html}
{receipts_html}
<p style="margin-top: 30px; font-size: 9pt; color: #888;">
Documento generato da PEChub il {date_val} ID messaggio: {message.id}
</p>
</body>
</html>"""
return HTMLResponse(content=html, media_type="text/html; charset=utf-8")
+114
View File
@@ -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)),
},
)
+106
View File
@@ -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)
+289 -11
View File
@@ -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=<uuid>.
"""
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=<uuid> 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)
+83
View File
@@ -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)
+15
View File
@@ -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)]
+7 -1
View File
@@ -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 ─────────────────────────────────────────────────────────────
+3
View File
@@ -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
+22 -1
View File
@@ -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()
)
+49
View File
@@ -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"<PecContact {self.email!r}>"
+1
View File
@@ -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
+114
View File
@@ -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"<RoutingRule {self.name!r} priority={self.priority}>"
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"),
)
+45
View File
@@ -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"<MessageTemplate {self.name!r}>"
+10
View File
@@ -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"<User {self.email!r} role={self.role!r}>"
+1 -1
View File
@@ -1 +1 @@
# Modulo notifiche mittenti multi-canale
-# Modulo notifiche mittenti multi-canale
+161
View File
@@ -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"<h2>PEChub Test canale Email</h2>"
f"<p>Il canale <strong>{channel_name}</strong> e' configurato correttamente.</p>"
f"<p>Data/ora: <em>{ts}</em><br>"
f"Destinatario: {to_email}</p>"
f"<hr><p style='font-size:11px;color:#888'>Inviato da PEChub Notification Engine</p>"
)
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,
)
+125
View File
@@ -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,
)
+125
View File
@@ -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,
)
+41
View File
@@ -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]
+12
View File
@@ -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]
+13
View File
@@ -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):
+56
View File
@@ -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] = []
+4
View File
@@ -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
+75
View File
@@ -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)
+113
View File
@@ -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
+4
View File
@@ -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
+51
View File
@@ -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
+53 -2
View File
@@ -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."
),
)
+153
View File
@@ -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,
)
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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) ───────────────────
+182 -44
View File
@@ -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 ───────────────────────────────────────────────────────────────
+240
View File
@@ -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())
+47 -11
View File
@@ -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
]
+594
View File
@@ -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",
]
@@ -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
+139
View File
@@ -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
+15 -2
View File
@@ -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__)
+116
View File
@@ -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()
+32
View File
@@ -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},
)
+12
View File
@@ -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]
+345 -2
View File
@@ -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",
+347 -3
View File
@@ -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",
+1
View File
@@ -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": {
+36 -8
View File
@@ -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() {
<Route path="/" element={<Navigate to="/inbox" replace />} />
{/* Vista globale: tutte le caselle insieme */}
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/archived" element={<InboxPage viewMode="archived" />} />
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/archived" element={<InboxPage viewMode="archived" />} />
<Route path="/trash" element={<InboxPage viewMode="trash" />} />
<Route path="/conservation-pending" element={<InboxPage viewMode="conservation_pending" />} />
<Route path="/conservation-archived" element={<InboxPage viewMode="conservation_archived" />} />
{/* Vista per singola casella PEC */}
<Route path="/mailbox/:mailboxId/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
<Route path="/mailbox/:mailboxId/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
<Route path="/mailbox/:mailboxId/trash" element={<InboxPage viewMode="trash" />} />
<Route path="/mailbox/:mailboxId/conservation-pending" element={<InboxPage viewMode="conservation_pending" />} />
<Route path="/mailbox/:mailboxId/conservation-archived" element={<InboxPage viewMode="conservation_archived" />} />
{/* Vista per Virtual Box assegnata */}
<Route path="/virtual-box/:vboxId/inbox" element={<InboxPage viewMode="inbox" />} />
@@ -74,6 +87,21 @@ export default function App() {
{/* Super Admin Gestione Multi-Tenant */}
<Route path="/multitenant" element={<MultiTenantPage />} />
{/* Ricerca avanzata full-text */}
<Route path="/search" element={<SearchPage />} />
{/* Dashboard e Reportistica */}
<Route path="/reports" element={<ReportsPage />} />
{/* Audit Log */}
<Route path="/audit-log" element={<AuditLogPage />} />
{/* Nuove funzionalita' */}
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/routing-rules" element={<RoutingRulesPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/deadlines" element={<DeadlinesPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
+41
View File
@@ -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<string, unknown> | null
outcome: 'success' | 'failure'
occurred_at: string
}
export type AuditLogListResponse = PaginatedResponse<AuditLogEntry>
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<AuditLogListResponse> =>
apiClient
.get<AuditLogListResponse>('/audit-log', { params })
.then((r) => r.data),
}
+65
View File
@@ -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<PecContactResponse[]>('/contacts/autocomplete', { params: { q } }).then((r) => r.data),
get: (id: string) =>
apiClient.get<PecContactResponse>(`/contacts/${id}`).then((r) => r.data),
create: (data: PecContactCreate) =>
apiClient.post<PecContactResponse>('/contacts', data).then((r) => r.data),
update: (id: string, data: PecContactUpdate) =>
apiClient.put<PecContactResponse>(`/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<ContactImportResult>('/contacts/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then((r) => r.data)
},
}
+31
View File
@@ -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<DeadlineMessageResponse[]>('/deadlines', { params }).then((r) => r.data),
setDeadline: (messageId: string, data: DeadlineSetRequest) =>
apiClient.post<DeadlineMessageResponse>(`/messages/${messageId}/deadline`, data).then((r) => r.data),
}
+12
View File
@@ -28,4 +28,16 @@ export const mailboxesApi = {
apiClient
.post<ConnectionTestResult>(`/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<string, number> }>('/mailboxes/unread-counts')
.then((r) => r.data.counts),
}
+80 -1
View File
@@ -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<MessageResponse>(`/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<MessageResponse>(`/messages/${id}`, { is_trashed: true })
.then((r) => r.data),
untrash: (id: string) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, { is_trashed: false })
.then((r) => r.data),
/** Sposta un messaggio nella cartella Da Conservare */
conserve: (id: string) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, { is_pending_conservation: true })
.then((r) => r.data),
/** Rimuove un messaggio dalla cartella Da Conservare */
unconserve: (id: string) =>
apiClient
.patch<MessageResponse>(`/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<MessageBulkUpdateResponse>('/messages/bulk', payload)
@@ -91,4 +127,47 @@ export const messagesApi = {
getReceipts: (id: string) =>
apiClient.get<MessageResponse[]>(`/messages/${id}/receipts`).then((r) => r.data),
// ─── Feature 3: Thread ────────────────────────────────────────────────────
getThread: (id: string) =>
apiClient.get<MessageResponse[]>(`/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<void> => {
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)
},
}
+99
View File
@@ -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<ReportSummaryResponse> => {
const res = await apiClient.get<ReportSummaryResponse>('/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()
},
}
+65
View File
@@ -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<RoutingRuleCreate>
export const routingRulesApi = {
list: () =>
apiClient.get<{ items: RoutingRuleResponse[]; total: number }>('/routing-rules').then((r) => r.data),
get: (id: string) =>
apiClient.get<RoutingRuleResponse>(`/routing-rules/${id}`).then((r) => r.data),
create: (data: RoutingRuleCreate) =>
apiClient.post<RoutingRuleResponse>('/routing-rules', data).then((r) => r.data),
update: (id: string, data: RoutingRuleUpdate) =>
apiClient.put<RoutingRuleResponse>(`/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<RoutingRuleResponse>(`/routing-rules/${id}/toggle`).then((r) => r.data),
}
+132 -4
View File
@@ -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<TenantSettingsResponse>('/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<IndexingStats> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.get<IndexingStats>('/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<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.get<IndexingJobStatus>('/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<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.post<IndexingJobStatus>(
'/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<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.delete<IndexingJobStatus>(
'/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<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.get<IndexingJobStatus>(
'/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<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.post<IndexingJobStatus>(
'/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<IndexingJobStatus> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.delete<IndexingJobStatus>(
'/settings/indexing/rescan',
{ params }
)
return data
},
}
+49
View File
@@ -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<TemplateResponse>(`/templates/${id}`).then((r) => r.data),
create: (data: TemplateCreate) =>
apiClient.post<TemplateResponse>('/templates', data).then((r) => r.data),
update: (id: string, data: TemplateUpdate) =>
apiClient.put<TemplateResponse>(`/templates/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/templates/${id}`).then((r) => r.data),
}
+259 -3
View File
@@ -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<Set<string>>(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() {
<Archive className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Archiviati</span>}
</NavLink>
{/* Cestino globale */}
<NavLink
to="/trash"
end
className={({ isActive }) =>
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}
>
<Trash2 className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Cestino</span>}
</NavLink>
{/* ── Sezione Conservazione (admin e supervisor) ── */}
{(isAdmin || isSupervisor) && (
<>
{!collapsed && (
<div className="pt-1 pb-0.5 px-1">
<p className="text-[10px] font-semibold text-teal-500/70 uppercase tracking-wider">
Conservazione
</p>
</div>
)}
<NavLink
to="/conservation-pending"
end
className={({ isActive }) =>
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}
>
<ShieldCheck className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Da Conservare</span>}
</NavLink>
<NavLink
to="/conservation-archived"
end
className={({ isActive }) =>
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}
>
<ShieldCheck className="h-4 w-4 flex-shrink-0 opacity-60" />
{!collapsed && <span>Storico</span>}
</NavLink>
</>
)}
</div>
</div>
@@ -290,6 +373,7 @@ export function Sidebar() {
collapsed={collapsed}
isExpanded={isMailboxExpanded(mailbox.id)}
onToggle={() => toggleMailbox(mailbox.id)}
unreadCount={unreadCounts[mailbox.id] ?? 0}
/>
))}
</div>
@@ -323,6 +407,77 @@ export function Sidebar() {
</div>
)}
{/* ── Strumenti operativi ── */}
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2 space-y-0.5">
<NavLink
to="/deadlines"
className={({ isActive }) =>
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}
>
<Calendar className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Scadenzario</span>}
</NavLink>
<NavLink
to="/contacts"
className={({ isActive }) =>
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}
>
<BookUser className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Rubrica PEC</span>}
</NavLink>
<NavLink
to="/search"
className={({ isActive }) =>
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}
>
<Search className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Ricerca</span>}
</NavLink>
<NavLink
to="/reports"
className={({ isActive }) =>
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}
>
<BarChart2 className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Dashboard</span>}
</NavLink>
</div>
</div>
{/* ── Nuova PEC ── */}
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
@@ -346,6 +501,40 @@ export function Sidebar() {
</div>
</div>
{/* ── Sezione Supervisione visibile solo ai supervisor ── */}
{isSupervisor && (
<div>
{!collapsed && (
<>
<div className="border-t border-gray-700 mx-4 mb-3" />
<p className="px-4 mb-1.5 text-xs font-semibold text-cyan-400 uppercase tracking-wider">
Supervisione
</p>
</>
)}
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
<NavLink
to="/mailboxes"
className={({ isActive }) =>
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}
>
<MailCheck className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Caselle PEC</span>}
</NavLink>
</div>
</div>
)}
{/* ── Sezione Amministrazione ── */}
{isAdmin && (
<div>
@@ -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) => (
<NavLink
key={item.to}
@@ -475,6 +667,7 @@ interface MailboxNavItemProps {
collapsed: boolean
isExpanded: boolean
onToggle: () => 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)` : ''}`}
>
<div className="relative">
<div className="h-6 w-6 rounded-full bg-gray-600 flex items-center justify-center text-xs font-semibold">
@@ -523,6 +718,11 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
dotClass,
)}
/>
{unreadCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 h-3.5 min-w-[14px] px-0.5 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</div>
</NavLink>
)
@@ -554,6 +754,13 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
{displayName}
</span>
{/* Badge non letti per casella */}
{unreadCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[16px] px-1 rounded-full bg-blue-500 text-white text-[10px] font-bold flex-shrink-0">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
{/* Chevron espandi/comprimi */}
<ChevronDown
className={cn(
@@ -625,6 +832,55 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
<Archive className="h-3.5 w-3.5 flex-shrink-0" />
<span>Archiviati</span>
</NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/trash`}
className={({ isActive }) =>
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',
)
}
>
<Trash2 className="h-3.5 w-3.5 flex-shrink-0" />
<span>Cestino</span>
</NavLink>
{/* Conservazione (solo admin/supervisor) */}
{canSeeConservation && (
<>
<NavLink
to={`/mailbox/${mailbox.id}/conservation-pending`}
className={({ isActive }) =>
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',
)
}
>
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0" />
<span>Da Conservare</span>
</NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/conservation-archived`}
className={({ isActive }) =>
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',
)
}
>
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 opacity-60" />
<span>Storico</span>
</NavLink>
</>
)}
</div>
)}
</div>
@@ -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}`)}
/>
))}
</div>
@@ -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 = (
<div className="flex items-start gap-3">
{/* Indicatore timeline */}
<div className="mt-1 flex flex-col items-center">
<div
className={`h-3 w-3 rounded-full border-2 ${
isRoot ? 'border-primary bg-primary/20' : 'border-muted-foreground bg-muted'
isRoot
? 'border-primary bg-primary/20'
: isClickable
? 'border-blue-500 bg-blue-100'
: 'border-muted-foreground bg-muted'
}`}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">{label}</span>
<span
className={`text-sm font-medium truncate ${
isClickable ? 'text-blue-600 group-hover:underline' : ''
}`}
>
{label}
</span>
{state && !isRoot && <PecStateBadge state={state} />}
{type && type !== 'posta_certificata' && <PecTypeBadge type={type} />}
{isClickable && (
<ExternalLink className="h-3.5 w-3.5 text-blue-400 group-hover:text-blue-600 flex-shrink-0" />
)}
</div>
{date && (
<p className="text-xs text-muted-foreground mt-0.5">{formatDate(date)}</p>
@@ -107,4 +127,19 @@ function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
</div>
</div>
)
if (isClickable) {
return (
<button
type="button"
onClick={onClick}
className="w-full text-left group rounded-md px-2 py-1 -mx-2 hover:bg-muted/50 transition-colors cursor-pointer"
title="Apri dettaglio ricevuta"
>
{content}
</button>
)
}
return <div>{content}</div>
}
+12
View File
@@ -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,
@@ -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 (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
isSuccess
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800',
)}
>
{isSuccess ? (
<ShieldCheck className="h-3 w-3" />
) : (
<AlertCircle className="h-3 w-3" />
)}
{isSuccess ? 'Successo' : 'Fallito'}
</span>
)
}
// ─── Etichetta azione leggibile ───────────────────────────────────────────────
function actionLabel(action: string): string {
const map: Record<string, string> = {
'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<AuditLogParams>({})
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 (
<div className="p-6 space-y-6">
{/* Intestazione */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Audit Log</h1>
<p className="text-sm text-gray-500 mt-1">
Registro cronologico degli eventi di sistema e delle operazioni degli utenti.
</p>
</div>
{/* Filtri */}
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Azione */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Azione</label>
<select
value={filterAction}
onChange={(e) => 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"
>
<option value="">Tutte le azioni</option>
<option value="auth.login">Login</option>
<option value="auth.password_changed">Cambio password</option>
<option value="user.created">Utente creato</option>
<option value="user.updated">Utente modificato</option>
<option value="user.deleted">Utente eliminato</option>
<option value="mailbox.created">Casella creata</option>
<option value="mailbox.updated">Casella modificata</option>
<option value="mailbox.deleted">Casella eliminata</option>
<option value="message.sent">PEC inviata</option>
</select>
</div>
{/* Esito */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Esito</label>
<select
value={filterOutcome}
onChange={(e) => 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"
>
<option value="">Tutti</option>
<option value="success">Successo</option>
<option value="failure">Fallito</option>
</select>
</div>
{/* Data da */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Dal</label>
<input
type="date"
value={filterDateFrom}
onChange={(e) => 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"
/>
</div>
{/* Data a */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Al</label>
<input
type="date"
value={filterDateTo}
onChange={(e) => 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"
/>
</div>
</div>
{/* Bottoni */}
<div className="flex gap-2 mt-4">
<button
onClick={handleSearch}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
<Search className="h-4 w-4" />
Cerca
</button>
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 transition-colors"
>
<RotateCcw className="h-4 w-4" />
Reimposta
</button>
</div>
</div>
{/* Tabella */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{/* Header tabella con count */}
<div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<span className="text-sm text-gray-500">
{isLoading ? 'Caricamento...' : `${total.toLocaleString('it-IT')} eventi trovati`}
</span>
{total > 0 && (
<span className="text-sm text-gray-400">
Pagina {page} di {pages}
</span>
)}
</div>
{/* Stato errore */}
{isError && (
<div className="p-8 text-center text-red-600">
<AlertCircle className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm">Errore nel caricamento dei dati.</p>
</div>
)}
{/* Stato vuoto */}
{!isLoading && !isError && items.length === 0 && (
<div className="p-8 text-center text-gray-500">
<ShieldCheck className="h-8 w-8 mx-auto mb-2 text-gray-300" />
<p className="text-sm">Nessun evento trovato.</p>
</div>
)}
{/* Dati */}
{items.length > 0 && (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Data / Ora
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Azione
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Esito
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Risorsa
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Utente
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{items.map((entry) => (
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
{/* Data/ora */}
<td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
{format(new Date(entry.occurred_at), 'dd/MM/yyyy HH:mm:ss', { locale: it })}
</td>
{/* Azione */}
<td className="px-4 py-3 text-sm whitespace-nowrap">
<span className="font-medium text-gray-900">
{actionLabel(entry.action)}
</span>
<span className="ml-1 text-gray-400 font-mono text-xs">
({entry.action})
</span>
</td>
{/* Esito */}
<td className="px-4 py-3 whitespace-nowrap">
<OutcomeBadge outcome={entry.outcome} />
</td>
{/* Risorsa */}
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
{entry.resource_type ? (
<span>
{entry.resource_type}
{entry.resource_id && (
<span className="ml-1 text-gray-400 font-mono text-xs">
{entry.resource_id.split('-')[0]}...
</span>
)}
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
{/* IP */}
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap font-mono">
{entry.ip_address ?? <span className="text-gray-300"></span>}
</td>
{/* Utente (UUID abbreviato) */}
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap font-mono">
{entry.user_id ? (
<span title={entry.user_id}>
{entry.user_id.split('-')[0]}...
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Paginazione */}
{pages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
<button
disabled={page <= 1}
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"
>
Precedente
</button>
<span className="text-sm text-gray-500">
{page} / {pages}
</span>
<button
disabled={page >= 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
</button>
</div>
)}
</div>
</div>
)
}
+58 -16
View File
@@ -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<HTMLInputElement>(null)
// Corpo HTML (gestito da TipTap)
const [bodyHtml, setBodyHtml] = useState<string>(() => {
if (!replyTo) return ''
const date = new Date(
replyTo.received_at || replyTo.created_at
).toLocaleDateString('it-IT')
return [
'<p></p>',
'<p></p>',
'<hr>',
`<p><strong>In risposta al messaggio del ${date}</strong></p>`,
`<p>Da: ${replyTo.from_address || ''}</p>`,
`<p>A: ${replyTo.to_addresses?.join(', ') || ''}</p>`,
`<p>Oggetto: ${replyTo.subject || ''}</p>`,
].join('')
if (replyTo) {
const date = new Date(
replyTo.received_at || replyTo.created_at
).toLocaleDateString('it-IT')
return [
'<p></p>',
'<p></p>',
'<hr>',
`<p><strong>In risposta al messaggio del ${date}</strong></p>`,
`<p>Da: ${replyTo.from_address || ''}</p>`,
`<p>A: ${replyTo.to_addresses?.join(', ') || ''}</p>`,
`<p>Oggetto: ${replyTo.subject || ''}</p>`,
].join('')
}
if (forwardOf) {
const date = new Date(
forwardOf.received_at || forwardOf.sent_at || forwardOf.created_at
).toLocaleDateString('it-IT')
const bodyContent = forwardOf.body_text
? `<pre style="white-space:pre-wrap;font-family:inherit">${forwardOf.body_text}</pre>`
: ''
return [
'<p></p>',
'<p></p>',
'<hr>',
`<p><strong>Messaggio inoltrato</strong></p>`,
`<p>Da: ${forwardOf.from_address || ''}</p>`,
`<p>A: ${forwardOf.to_addresses?.join(', ') || ''}</p>`,
`<p>Data: ${date}</p>`,
`<p>Oggetto: ${forwardOf.subject || ''}</p>`,
bodyContent,
].join('')
}
return ''
})
// Allegati
@@ -80,12 +102,16 @@ export function ComposePage() {
formState: { errors },
} = useForm<ComposeFormValues>({
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() {
</Button>
<div>
<h1 className="text-lg font-semibold">
{replyTo ? 'Rispondi a PEC' : 'Nuova PEC'}
{replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC'}
</h1>
{replyTo && (
<p className="text-xs text-muted-foreground">
In risposta a: {replyTo.subject}
</p>
)}
{forwardOf && (
<p className="text-xs text-muted-foreground">
Inoltro di: {forwardOf.subject}
</p>
)}
</div>
</div>
@@ -239,6 +270,17 @@ export function ComposePage() {
</p>
</div>
{/* Avviso allegati messaggio inoltrato */}
{forwardOf && forwardOf.has_attachments && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
<Paperclip className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-700">
Il messaggio originale contiene allegati. Per includerli nell'inoltro,
scaricali dal messaggio originale e aggiungili qui manualmente.
</p>
</div>
)}
{/* Casella mittente */}
<div className="space-y-1.5">
<Label htmlFor="mailbox_id">Casella mittente *</Label>
@@ -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<PecContactResponse | null>(null)
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
importMutation.mutate(file)
e.target.value = ''
}
}
const items = data?.items ?? []
const total = data?.total ?? 0
return (
<div className="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">Rubrica PEC</h1>
<p className="text-sm text-muted-foreground">
{total} contatti nella rubrica
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => fileInputRef.current?.click()} isLoading={importMutation.isPending}>
<Upload className="h-4 w-4 mr-2" />
Importa CSV
</Button>
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileChange} />
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuovo contatto
</Button>
</div>
</div>
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
<div className="relative w-full max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca per email, nome o organizzazione..."
value={q}
onChange={(e) => setQ(e.target.value)}
className="pl-9"
/>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<BookUser className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessun contatto trovato</p>
<p className="text-sm mt-1">
Aggiungi contatti manualmente o importa un CSV con colonne: email, name, organization
</p>
</div>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Email PEC</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Nome</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Organizzazione</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Tipo</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Aggiornato</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y">
{items.map((c) => (
<tr key={c.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-mono text-xs">{c.email}</td>
<td className="px-4 py-3">{c.name ?? '-'}</td>
<td className="px-4 py-3 text-muted-foreground">
{c.organization ? (
<span className="flex items-center gap-1">
<Building2 className="h-3.5 w-3.5" />
{c.organization}
</span>
) : '-'}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${c.auto_saved ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}`}>
{c.auto_saved ? 'Automatico' : 'Manuale'}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(c.updated_at)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => 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"
>
<Star className={`h-4 w-4 ${c.is_favorite ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`} />
</button>
<button
onClick={() => openEdit(c)}
className="p-1 rounded hover:bg-muted transition-colors"
title="Modifica"
>
<Pencil className="h-4 w-4 text-muted-foreground" />
</button>
<button
onClick={() => {
if (confirm(`Eliminare il contatto ${c.email}?`)) deleteMutation.mutate(c.id)
}}
className="p-1 rounded hover:bg-muted transition-colors"
title="Elimina"
>
<Trash2 className="h-4 w-4 text-destructive" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica contatto' : 'Nuovo contatto'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Indirizzo PEC *</Label>
<Input value={formEmail} onChange={(e) => setFormEmail(e.target.value)} placeholder="indirizzo@pec.it" disabled={!!editing} type="email" />
</div>
<div className="space-y-2">
<Label>Nome</Label>
<Input value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="Mario Rossi" />
</div>
<div className="space-y-2">
<Label>Organizzazione</Label>
<Input value={formOrg} onChange={(e) => setFormOrg(e.target.value)} placeholder="Comune di Roma" />
</div>
<div className="space-y-2">
<Label>Note</Label>
<Input value={formNotes} onChange={(e) => setFormNotes(e.target.value)} placeholder="Note aggiuntive..." />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={formFavorite} onChange={(e) => setFormFavorite(e.target.checked)} className="rounded" />
<span className="text-sm">Aggiungi ai preferiti</span>
</label>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending}>
{editing ? 'Salva' : 'Aggiungi'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -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 (
<div
className={`flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer ${item.is_overdue ? 'border-destructive/30 bg-destructive/5' : ''}`}
onClick={() => navigate(`/messages/${item.id}`)}
>
<div className="flex-shrink-0">
{item.is_overdue ? (
<AlertTriangle className="h-5 w-5 text-destructive" />
) : (
<Clock className="h-5 w-5 text-amber-500" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.subject || '(nessun oggetto)'}</p>
<p className="text-xs text-muted-foreground">
{item.direction === 'inbound' ? `Da: ${item.from_address}` : `A: ${(item.to_addresses ?? []).join(', ')}`}
</p>
{item.deadline_note && (
<p className="text-xs text-muted-foreground italic mt-0.5">{item.deadline_note}</p>
)}
</div>
<div className="flex-shrink-0 text-right">
<p className={`text-sm font-semibold ${item.is_overdue ? 'text-destructive' : 'text-amber-600'}`}>
{deadlineDate ? formatDate(deadlineDate.toISOString()) : '-'}
</p>
{item.is_overdue && (
<p className="text-xs text-destructive">Scaduto</p>
)}
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
)
}
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 (
<div className="space-y-2">
<div className={`flex items-center gap-2 ${color}`}>
<Icon className="h-4 w-4" />
<h3 className="font-semibold text-sm">{title} ({items.length})</h3>
</div>
<div className="space-y-2">
{items.map((item) => (
<DeadlineItem key={item.id} item={item} />
))}
</div>
</div>
)
}
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 (
<div className="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<Calendar className="h-5 w-5 text-primary" />
Scadenzario
</h1>
<p className="text-sm text-muted-foreground">
{total} messaggi con scadenze
</p>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={includeOverdue}
onChange={(e) => setIncludeOverdue(e.target.checked)}
className="rounded"
/>
Includi scaduti
</label>
<select
value={daysAhead}
onChange={(e) => setDaysAhead(Number(e.target.value))}
className="text-sm border rounded-md px-3 py-1.5 bg-background"
>
<option value={7}>Prossimi 7 giorni</option>
<option value={30}>Prossimi 30 giorni</option>
<option value={90}>Prossimi 90 giorni</option>
<option value={365}>Prossimo anno</option>
</select>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : total === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckCircle2 className="h-12 w-12 mx-auto mb-4 opacity-30 text-green-500" />
<p className="text-lg font-medium">Nessuna scadenza trovata</p>
<p className="text-sm mt-1">
Le scadenze si impostano dal dettaglio di ogni messaggio.
</p>
</div>
) : (
<div className="max-w-3xl mx-auto space-y-8">
<DeadlineGroup
title="Scaduti"
items={groups.overdue}
icon={AlertTriangle}
color="text-destructive"
/>
<DeadlineGroup
title="Oggi"
items={groups.today}
icon={Clock}
color="text-amber-600"
/>
<DeadlineGroup
title="Questa settimana"
items={groups.thisWeek}
icon={Calendar}
color="text-blue-600"
/>
<DeadlineGroup
title="Successivamente"
items={groups.later}
icon={Calendar}
color="text-muted-foreground"
/>
</div>
)}
</div>
</div>
)
}
+451 -81
View File
@@ -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<Set<string>>(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) {
<RefreshCw className="h-4 w-4 mr-1" />
Aggiorna
</Button>
<Button size="sm" onClick={() => navigate('/compose')}>
<Send className="h-4 w-4 mr-1" />
Nuova PEC
</Button>
{viewMode !== 'trash' && (
<Button size="sm" onClick={() => navigate('/compose')}>
<Send className="h-4 w-4 mr-1" />
Nuova PEC
</Button>
)}
</div>
</div>
@@ -440,13 +561,107 @@ export function InboxPage({ viewMode }: InboxPageProps) {
Preferiti
</Button>
)}
{/* Pulsante filtri avanzati */}
<Button
variant="outline"
size="sm"
className={cn('h-9 text-xs', activeAdvancedFiltersCount > 0 && 'border-primary text-primary')}
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
>
<SlidersHorizontal className="h-3.5 w-3.5 mr-1" />
Filtri
{activeAdvancedFiltersCount > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center h-4 w-4 rounded-full bg-primary text-white text-[10px] font-bold">
{activeAdvancedFiltersCount}
</span>
)}
{showAdvancedFilters ? <ChevronUp className="h-3 w-3 ml-1" /> : <ChevronDown className="h-3 w-3 ml-1" />}
</Button>
</div>
{/* ── Pannello filtri avanzati ── */}
{showAdvancedFilters && (
<div className="mt-3 bg-muted/40 rounded-lg border p-3 grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Data da */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Data da</label>
<input
type="date"
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
{/* Data a */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Data a</label>
<input
type="date"
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
{/* Tipo PEC */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Tipo PEC</label>
<select
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
value={pecTypeFilter}
onChange={(e) => setPecTypeFilter(e.target.value)}
>
<option value="">Tutti i tipi</option>
<option value="posta_certificata">Posta certificata</option>
<option value="accettazione">Accettazione</option>
<option value="avvenuta_consegna">Avvenuta consegna</option>
<option value="mancata_consegna">Mancata consegna</option>
<option value="non_accettazione">Non accettazione</option>
<option value="errore_consegna">Errore consegna</option>
</select>
</div>
{/* Stato PEC */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Stato PEC</label>
<select
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
value={pecStateFilter}
onChange={(e) => setPecStateFilter(e.target.value)}
>
<option value="">Tutti gli stati</option>
<option value="received">Ricevuto</option>
<option value="sent">Inviato</option>
<option value="accepted">Accettato</option>
<option value="delivered">Consegnato</option>
<option value="anomaly">Anomalia</option>
<option value="failed">Fallito</option>
</select>
</div>
{/* Reset filtri */}
{activeAdvancedFiltersCount > 0 && (
<div className="flex items-end col-span-2 md:col-span-1">
<Button
variant="ghost"
size="sm"
className="h-8 text-xs text-muted-foreground w-full"
onClick={() => { setDateFrom(''); setDateTo(''); setPecTypeFilter(''); setPecStateFilter('') }}
>
<X className="h-3.5 w-3.5 mr-1" />
Azzera filtri
</Button>
</div>
)}
</div>
)}
</div>
{/* ── Barra azioni bulk (appare quando ci sono selezioni) ── */}
{/* ── Barra azioni bulk ── */}
{someSelected && (
<div className="border-b bg-blue-50 dark:bg-blue-950/30 px-6 py-2.5 flex items-center gap-3 flex-wrap">
{/* Contatore + deseleziona */}
<div className="flex items-center gap-2">
<button
onClick={handleClearSelection}
@@ -462,8 +677,22 @@ export function InboxPage({ viewMode }: InboxPageProps) {
<div className="h-4 w-px bg-blue-200 dark:bg-blue-700" />
{/* Azioni: variano in base alla vista */}
{viewMode !== 'starred' && (
{/* Segna come da leggere (solo in modalita' casella, non vbox) */}
{isMailboxMode && viewMode !== 'trash' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkMarkUnread}
isLoading={bulkMutation.isPending}
>
<MailX className="h-3.5 w-3.5 mr-1" />
Segna da leggere
</Button>
)}
{/* Stella */}
{viewMode !== 'starred' && viewMode !== 'trash' && (
<Button
variant="outline"
size="sm"
@@ -502,7 +731,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
</Button>
)}
{viewMode !== 'archived' && (
{viewMode !== 'archived' && viewMode !== 'trash' && (
<Button
variant="outline"
size="sm"
@@ -528,16 +757,73 @@ export function InboxPage({ viewMode }: InboxPageProps) {
</Button>
)}
{/* Assegna tag bulk */}
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={() => setShowTagSelector(true)}
>
<Tag className="h-3.5 w-3.5 mr-1 text-primary" />
Tag
</Button>
{/* Cestino / Ripristina cestino (solo modalita' casella) */}
{isMailboxMode && viewMode !== 'trash' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-red-200 hover:bg-red-50 dark:border-red-800 text-red-600"
onClick={handleBulkTrash}
isLoading={bulkMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Cestino
</Button>
)}
{isMailboxMode && viewMode === 'trash' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkUntrash}
isLoading={bulkMutation.isPending}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
Ripristina dal cestino
</Button>
)}
{/* Tag */}
{viewMode !== 'trash' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={() => setShowTagSelector(true)}
>
<Tag className="h-3.5 w-3.5 mr-1 text-primary" />
Tag
</Button>
)}
{/* Invia a Conservazione (solo admin/supervisor, non nelle viste conservazione) */}
{canConserve && viewMode !== 'conservation_pending' && viewMode !== 'conservation_archived' && viewMode !== 'trash' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-teal-200 hover:bg-teal-50 dark:border-teal-800 text-teal-700"
onClick={handleBulkConserve}
isLoading={bulkMutation.isPending}
>
<ShieldCheck className="h-3.5 w-3.5 mr-1" />
Invia a Conservazione
</Button>
)}
{/* Rimuovi da Da Conservare */}
{canConserve && viewMode === 'conservation_pending' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-orange-200 hover:bg-orange-50 dark:border-orange-800 text-orange-700"
onClick={handleBulkUnconserve}
isLoading={bulkMutation.isPending}
>
<ShieldX className="h-3.5 w-3.5 mr-1" />
Rimuovi da Da Conservare
</Button>
)}
</div>
)}
@@ -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'}
</p>
</div>
) : (
<div className="divide-y">
{/* Riga "seleziona tutto" */}
{messages.length > 0 && (
<div className="flex items-center gap-3 px-6 py-2 bg-muted/20 border-b">
<button
@@ -594,6 +881,8 @@ export function InboxPage({ viewMode }: InboxPageProps) {
key={message.id}
message={message}
viewMode={viewMode}
isMailboxMode={isMailboxMode}
canConserve={canConserve}
isSelected={selectedIds.has(message.id)}
onSelect={(e) => 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({
</p>
</div>
{/* Tag badges */}
{message.labels && message.labels.length > 0 && (
<div className="mt-1" onClick={(e) => e.stopPropagation()}>
<TagBadgeList labels={message.labels} maxVisible={4} size="sm" />
@@ -784,9 +1093,9 @@ function MessageRow({
)}
</div>
{/* ── Azioni rapide (visibili su hover) + indicatori fissi ── */}
{/* ── Azioni rapide (visibili su hover) ── */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Pulsante stella (rapido, su hover o se stellata) */}
{/* Stella */}
<button
onClick={onToggleStar}
title={message.is_starred ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
@@ -807,21 +1116,82 @@ function MessageRow({
/>
</button>
{/* Pulsante archivia/ripristina (rapido, su hover) */}
<button
onClick={onToggleArchive}
title={viewMode === 'archived' ? 'Ripristina dalla posta' : 'Archivia'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
{viewMode === 'archived' ? (
<ArchiveX className="h-4 w-4 text-muted-foreground" />
) : (
<Archive className="h-4 w-4 text-muted-foreground" />
)}
</button>
{/* Segna come da leggere (solo in modalita' casella, solo messaggi gia' letti) */}
{isMailboxMode && message.is_read && viewMode !== 'trash' && (
<button
onClick={onMarkUnread}
title="Segna come da leggere"
className={cn(
'p-1 rounded hover:bg-muted transition-all',
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
<MailX className="h-4 w-4 text-muted-foreground" />
</button>
)}
{/* Archivia/Ripristina (non nel cestino) */}
{viewMode !== 'trash' && (
<button
onClick={onToggleArchive}
title={viewMode === 'archived' ? 'Ripristina dalla posta' : 'Archivia'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
{viewMode === 'archived' ? (
<ArchiveX className="h-4 w-4 text-muted-foreground" />
) : (
<Archive className="h-4 w-4 text-muted-foreground" />
)}
</button>
)}
{/* Cestino / Ripristina dal cestino (solo in modalita' casella) */}
{isMailboxMode && (
<button
onClick={onToggleTrash}
title={viewMode === 'trash' ? 'Ripristina dal cestino' : 'Sposta nel cestino'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
{viewMode === 'trash' ? (
<RotateCcw className="h-4 w-4 text-muted-foreground" />
) : (
<Trash2 className="h-4 w-4 text-muted-foreground" />
)}
</button>
)}
{/* Invia a Conservazione / Rimuovi da Da Conservare */}
{canConserve && viewMode !== 'trash' && viewMode !== 'conservation_archived' && (
<button
onClick={onToggleConserve}
title={viewMode === 'conservation_pending' ? 'Rimuovi da Da Conservare' : 'Invia a Conservazione'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
message.is_pending_conservation
? 'opacity-100'
: hovered
? 'opacity-100'
: 'opacity-0 pointer-events-none',
)}
>
{viewMode === 'conservation_pending' ? (
<ShieldX className="h-4 w-4 text-orange-500" />
) : (
<ShieldCheck
className={cn(
'h-4 w-4',
message.is_pending_conservation ? 'text-teal-600' : 'text-muted-foreground',
)}
/>
)}
</button>
)}
{/* Indicatore allegati */}
{message.has_attachments && (
+78 -39
View File
@@ -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<MailboxResponse | null>(null)
const [testingId, setTestingId] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
const [syncingId, setSyncingId] = useState<string | null>(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() {
<h1 className="text-xl font-semibold">Caselle PEC</h1>
<span className="text-sm text-muted-foreground">({mailboxes.length})</span>
</div>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Aggiungi casella
</Button>
{isAdmin && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Aggiungi casella
</Button>
)}
</div>
{/* Contenuto */}
@@ -111,12 +133,14 @@ export function MailboxesPage() {
<MailCheck className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground font-medium">Nessuna casella PEC configurata</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Aggiungi una casella per iniziare a gestire le PEC
{isAdmin ? 'Aggiungi una casella per iniziare a gestire le PEC' : 'Nessuna casella disponibile'}
</p>
<Button className="mt-4" onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Aggiungi casella
</Button>
{isAdmin && (
<Button className="mt-4" onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Aggiungi casella
</Button>
)}
</div>
) : (
<div className="grid gap-4">
@@ -168,38 +192,53 @@ export function MailboxesPage() {
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Test connessione */}
<Button
variant="outline"
size="sm"
onClick={() => handleTest(mailbox)}
isLoading={testingId === mailbox.id}
title="Testa connessione IMAP"
>
<TestTube className="h-4 w-4 mr-1" />
Test
</Button>
{isAdmin && (
<div className="flex items-center gap-2 flex-shrink-0">
{/* Test connessione */}
<Button
variant="outline"
size="sm"
onClick={() => handleTest(mailbox)}
isLoading={testingId === mailbox.id}
title="Testa connessione IMAP"
>
<TestTube className="h-4 w-4 mr-1" />
Test
</Button>
{/* Modifica */}
<Button
variant="outline"
size="sm"
onClick={() => setEditingMailbox(mailbox)}
>
<Edit className="h-4 w-4" />
</Button>
{/* Forza sincronizzazione */}
<Button
variant="outline"
size="sm"
onClick={() => handleForceSync(mailbox)}
isLoading={syncingId === mailbox.id}
title="Forza sincronizzazione IMAP immediata"
disabled={mailbox.status === 'deleted'}
>
<RefreshCw className="h-4 w-4 mr-1" />
Sync
</Button>
{/* Elimina */}
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(mailbox)}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Modifica */}
<Button
variant="outline"
size="sm"
onClick={() => setEditingMailbox(mailbox)}
>
<Edit className="h-4 w-4" />
</Button>
{/* Elimina */}
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(mailbox)}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
})}
@@ -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 (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Conversazione ({thread.length} messaggi)
</h3>
<div className="space-y-1.5 rounded-lg border bg-background p-3">
{thread.map((msg) => {
const isCurrent = msg.id === currentId
return (
<button
key={msg.id}
type="button"
onClick={() => !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'
}`}
>
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${msg.direction === 'inbound' ? 'bg-blue-500' : 'bg-green-500'}`} />
<div className="flex-1 min-w-0">
<p className={`text-sm truncate ${isCurrent ? 'font-semibold' : 'font-medium'}`}>
{msg.subject || '(nessun oggetto)'}
</p>
<p className="text-xs text-muted-foreground">
{msg.direction === 'inbound' ? msg.from_address : `A: ${(msg.to_addresses || []).join(', ')}`}
{' · '}
{formatDate(msg.received_at || msg.sent_at || msg.created_at)}
</p>
</div>
{isCurrent && <span className="text-xs text-primary flex-shrink-0">Questo</span>}
</button>
)
})}
</div>
</div>
)
}
// ─── 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="relative bg-background rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="font-medium truncate">{filename}</span>
<button onClick={onClose} className="p-1 rounded hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-auto p-2 min-h-[400px] flex items-center justify-center">
{isLoading && (
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
)}
{!isLoading && data && (
<>
{data.previewable && data.url ? (
<>
{contentType.startsWith('image/') ? (
<img src={data.url} alt={filename} className="max-w-full max-h-full object-contain" />
) : contentType === 'application/pdf' ? (
<iframe src={data.url} className="w-full h-[70vh]" title={filename} />
) : null}
</>
) : (
<div className="text-center text-muted-foreground">
<Eye className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>Anteprima non disponibile per questo tipo di file.</p>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}
// ─── 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() {
/>
</Button>
{/* Archivia (se non ancora archiviato) */}
{!message.is_archived && (
{/* Segna come da leggere (solo se gia' letto) */}
{message.is_read && !message.is_trashed && (
<Button
variant="ghost"
size="icon"
onClick={() => markUnreadMutation.mutate()}
title="Segna come da leggere"
isLoading={markUnreadMutation.isPending}
>
<MailX className="h-5 w-5 text-muted-foreground" />
</Button>
)}
{/* Archivia (se non ancora archiviato e non nel cestino) */}
{!message.is_archived && !message.is_trashed && (
<Button
variant="ghost"
size="icon"
@@ -211,7 +399,7 @@ export function MessageDetailPage() {
)}
{/* Ripristina dall'archivio (se archiviato) */}
{message.is_archived && (
{message.is_archived && !message.is_trashed && (
<Button
variant="outline"
size="sm"
@@ -224,8 +412,35 @@ export function MessageDetailPage() {
</Button>
)}
{/* 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 && (
<Button
variant="ghost"
size="icon"
onClick={() => trashMutation.mutate()}
title="Sposta nel cestino"
isLoading={trashMutation.isPending}
>
<Trash2 className="h-5 w-5 text-muted-foreground" />
</Button>
)}
{/* Ripristina dal cestino (se nel cestino) */}
{message.is_trashed && (
<Button
variant="outline"
size="sm"
onClick={() => untrashMutation.mutate()}
title="Ripristina dal cestino"
isLoading={untrashMutation.isPending}
>
<RotateCcw className="h-4 w-4 mr-1" />
Ripristina
</Button>
)}
{/* Rispondi (solo per messaggi inbound PEC certificata, non nel cestino) */}
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (
<Button
variant="outline"
size="sm"
@@ -239,11 +454,109 @@ export function MessageDetailPage() {
Rispondi
</Button>
)}
{/* Inoltra (disponibile per qualsiasi messaggio PEC certificata, non nel cestino) */}
{message.pec_type === 'posta_certificata' && !message.is_trashed && (
<Button
variant="outline"
size="sm"
onClick={() =>
navigate('/compose', {
state: { forwardOf: message },
})
}
>
<Forward className="h-4 w-4 mr-1" />
Inoltra
</Button>
)}
{/* Scarica pacchetto completo ZIP (sempre visibile) */}
<Button
variant="outline"
size="sm"
onClick={handleDownloadPackage}
disabled={isDownloadingPackage}
title="Scarica pacchetto completo (ZIP con tutti i file originali)"
>
{isDownloadingPackage ? (
<div className="h-4 w-4 mr-1 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<PackageOpen className="h-4 w-4 mr-1" />
)}
Scarica
</Button>
{/* Stampa (Feature 8) */}
<Button
variant="outline"
size="sm"
isLoading={isPrinting}
onClick={async () => {
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"
>
<Printer className="h-4 w-4 mr-1" />
Stampa
</Button>
{/* Scadenza (Feature 4) */}
<Button
variant="outline"
size="sm"
onClick={() => {
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' : ''}
>
<Calendar className="h-4 w-4 mr-1" />
{(message as any).deadline_at ? 'Scadenza' : 'Scadenza'}
</Button>
</div>
</div>
{/* Banner "Nel Cestino" */}
{message.is_trashed && (
<div className="bg-red-50 border-b border-red-200 px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-red-700">
<Trash2 className="h-4 w-4" />
<span>Questo messaggio si trova nel cestino.</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs border-red-300 hover:bg-red-100 text-red-700"
onClick={() => untrashMutation.mutate()}
isLoading={untrashMutation.isPending}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
Ripristina
</Button>
</div>
)}
{/* Banner "Archiviato" */}
{message.is_archived && (
{message.is_archived && !message.is_trashed && (
<div className="bg-amber-50 border-b border-amber-200 px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-amber-700">
<Archive className="h-4 w-4" />
@@ -379,25 +692,39 @@ export function MessageDetailPage() {
Allegati ({attachments.length})
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{attachments.map((att) => (
<button
key={att.id}
type="button"
onClick={() => 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"
>
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Paperclip className="h-5 w-5 text-primary" />
{attachments.map((att) => {
const isPreviewable = att.content_type?.startsWith('image/') || att.content_type === 'application/pdf'
return (
<div key={att.id} className="flex items-center gap-2 rounded-lg border bg-background p-3">
<button
type="button"
onClick={() => handleDownloadAttachment(att)}
className="flex items-center gap-3 flex-1 min-w-0 text-left hover:opacity-80"
>
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Paperclip className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{att.filename}</p>
<p className="text-xs text-muted-foreground">
{att.content_type || 'Tipo sconosciuto'} {formatBytes(att.size_bytes)}
</p>
</div>
<Download className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</button>
{isPreviewable && (
<button
type="button"
onClick={() => 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"
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{att.filename}</p>
<p className="text-xs text-muted-foreground">
{att.content_type || 'Tipo sconosciuto'} {formatBytes(att.size_bytes)}
</p>
</div>
<Download className="h-4 w-4 text-muted-foreground group-hover:text-foreground flex-shrink-0" />
</button>
))}
)
})}
</div>
</div>
)}
@@ -415,13 +742,48 @@ export function MessageDetailPage() {
</div>
)}
{/* Scadenza (Feature 4) */}
{(message as any).deadline_at && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-amber-700">
<Calendar className="h-4 w-4" />
<span>
<strong>Scadenza:</strong> {formatDate((message as any).deadline_at)}
</span>
{(message as any).deadline_note && (
<span className="text-amber-600 italic"> {(message as any).deadline_note}</span>
)}
</div>
<button
onClick={() => {
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
</button>
</div>
</div>
)}
{/* Thread (Feature 3) */}
{message.pec_type === 'posta_certificata' && (
<ThreadSection messageId={message.id} currentId={message.id} navigate={navigate} />
)}
{/* Messaggio originale per ricevute */}
{message.direction === 'inbound' && message.pec_type !== 'posta_certificata' && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-center gap-2 text-sm text-blue-700">
<Mail className="h-4 w-4" />
<span>
Questo è un messaggio automatico di tipo{' '}
Questo e un messaggio automatico di tipo{' '}
<strong>
<PecTypeBadge type={message.pec_type} />
</strong>
@@ -432,6 +794,63 @@ export function MessageDetailPage() {
</div>
</div>
{/* Modali */}
{/* Deadline form (Feature 4) */}
{showDeadlineForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl p-6 w-96 space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Calendar className="h-5 w-5 text-amber-500" />
Imposta scadenza
</h3>
<div className="space-y-2">
<Label>Data e ora scadenza</Label>
<Input
type="datetime-local"
value={deadlineDate}
onChange={e => setDeadlineDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Nota (opzionale)</Label>
<Input
value={deadlineNote}
onChange={e => setDeadlineNote(e.target.value)}
placeholder="Es. Termine per impugnare, entro 30 giorni..."
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setShowDeadlineForm(false)}>Annulla</Button>
<Button
onClick={async () => {
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
</Button>
</div>
</div>
</div>
)}
{/* Attachment preview (Feature 7) */}
{previewAtt && (
<AttachmentPreviewModal
messageId={message.id}
attachmentId={previewAtt.id}
filename={previewAtt.filename}
contentType={previewAtt.contentType}
onClose={() => setPreviewAtt(null)}
/>
)}
{/* Dialog gestione tag */}
{showTagSelector && (
<TagSelector
+528
View File
@@ -0,0 +1,528 @@
/**
* ReportsPage Dashboard e Reportistica (Fase 7).
*
* Visualizza:
* - 6 KPI cards (ricevute oggi, inviate oggi, anomalie, tasso consegna,
* caselle in errore, messaggi non letti)
* - Selettore periodo (7 / 30 giorni)
* - Grafico a barre: PEC ricevute vs inviate per giorno
* - Grafico a torta: distribuzione stati messaggi outbound
* - Tabella: dettaglio per casella
* - Pulsanti export CSV e PDF
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
PieChart,
Pie,
Cell,
ResponsiveContainer,
} from 'recharts'
import {
Inbox,
Send,
AlertTriangle,
CheckCircle2,
ServerCrash,
MailOpen,
Download,
FileText,
RefreshCw,
BarChart2,
} from 'lucide-react'
import { format, parseISO } from 'date-fns'
import { it } from 'date-fns/locale'
import { reportsApi } from '@/api/reports.api'
import { cn } from '@/lib/utils'
// ─── Costanti ─────────────────────────────────────────────────────────────────
const PERIOD_OPTIONS = [
{ label: '7 giorni', value: 7 },
{ label: '30 giorni', value: 30 },
{ label: '90 giorni', value: 90 },
]
const STATE_COLORS: Record<string, string> = {
delivered: '#22c55e',
accepted: '#3b82f6',
sent: '#8b5cf6',
anomaly: '#ef4444',
failed: '#dc2626',
queued: '#f59e0b',
draft: '#6b7280',
unknown: '#9ca3af',
}
const STATE_LABELS: Record<string, string> = {
delivered: 'Consegnata',
accepted: 'Accettata',
sent: 'Inviata',
anomaly: 'Anomalia',
failed: 'Fallita',
queued: 'In coda',
draft: 'Bozza',
unknown: 'Sconosciuto',
}
const STATUS_BADGE: Record<string, string> = {
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 (
<div
className={cn(
'bg-white rounded-xl border p-5 flex items-start gap-4',
alert && 'border-red-300 bg-red-50',
)}
>
<div className={cn('p-2.5 rounded-lg flex-shrink-0', color)}>
{icon}
</div>
<div className="min-w-0">
<p className="text-sm text-gray-500 font-medium truncate">{title}</p>
<p className={cn('text-2xl font-bold mt-0.5', alert ? 'text-red-700' : 'text-gray-900')}>
{value}
</p>
{subtitle && (
<p className="text-xs text-gray-400 mt-0.5">{subtitle}</p>
)}
</div>
</div>
)
}
// ─── Tooltip grafico barre ────────────────────────────────────────────────────
function CustomBarTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null
return (
<div className="bg-white border rounded-lg shadow-lg p-3 text-sm">
<p className="font-semibold text-gray-700 mb-1">{label}</p>
{payload.map((p: any) => (
<p key={p.dataKey} style={{ color: p.fill }}>
{p.name}: <strong>{p.value}</strong>
</p>
))}
</div>
)
}
// ─── Tooltip grafico torta ────────────────────────────────────────────────────
function CustomPieTooltip({ active, payload }: any) {
if (!active || !payload?.length) return null
const item = payload[0]
return (
<div className="bg-white border rounded-lg shadow-lg p-3 text-sm">
<p className="font-semibold" style={{ color: item.payload.fill }}>
{STATE_LABELS[item.name] ?? item.name}
</p>
<p className="text-gray-700">Totale: <strong>{item.value}</strong></p>
</div>
)
}
// ─── 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 (
<div className="flex flex-col h-full overflow-y-auto bg-gray-50">
{/* ── Intestazione ── */}
<div className="sticky top-0 z-10 bg-white border-b px-6 py-4 flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<BarChart2 className="h-6 w-6 text-blue-600 flex-shrink-0" />
<div className="min-w-0">
<h1 className="text-xl font-bold text-gray-900">Dashboard</h1>
{generatedAt && (
<p className="text-xs text-gray-400">Aggiornato il {generatedAt}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Selettore periodo */}
<div className="flex rounded-lg border bg-white overflow-hidden text-sm">
{PERIOD_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => 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}
</button>
))}
</div>
{/* Aggiorna */}
<button
onClick={() => 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"
>
<RefreshCw className={cn('h-4 w-4', isFetching && 'animate-spin')} />
</button>
{/* Export CSV */}
<button
onClick={() => 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"
>
<Download className="h-4 w-4" />
CSV
</button>
{/* Export PDF */}
<button
onClick={() => 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"
>
<FileText className="h-4 w-4" />
PDF
</button>
</div>
</div>
{/* ── Contenuto ── */}
<div className="flex-1 p-6 space-y-6 max-w-7xl mx-auto w-full">
{/* ── Loading / Errore ── */}
{isLoading && (
<div className="flex items-center justify-center h-64 text-gray-500">
<RefreshCw className="h-6 w-6 animate-spin mr-2" />
Caricamento dati...
</div>
)}
{isError && (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center text-red-700">
Errore nel caricamento dei dati. Riprova.
</div>
)}
{data && (
<>
{/* ── Riga KPI ── */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
<KpiCard
title="Ricevute oggi"
value={kpi!.received_today}
subtitle={`${kpi!.received_7d} negli ultimi 7gg`}
icon={<Inbox className="h-5 w-5 text-blue-600" />}
color="bg-blue-50"
/>
<KpiCard
title="Inviate oggi"
value={kpi!.sent_today}
subtitle={`${kpi!.sent_7d} negli ultimi 7gg`}
icon={<Send className="h-5 w-5 text-indigo-600" />}
color="bg-indigo-50"
/>
<KpiCard
title="Anomalie attive"
value={kpi!.anomalie_attive}
icon={<AlertTriangle className="h-5 w-5 text-orange-600" />}
color="bg-orange-50"
alert={kpi!.anomalie_attive > 0}
/>
<KpiCard
title="Tasso consegna"
value={`${kpi!.tasso_consegna}%`}
icon={<CheckCircle2 className="h-5 w-5 text-green-600" />}
color="bg-green-50"
/>
<KpiCard
title="Caselle in errore"
value={kpi!.caselle_in_errore}
icon={<ServerCrash className="h-5 w-5 text-red-600" />}
color="bg-red-50"
alert={kpi!.caselle_in_errore > 0}
/>
<KpiCard
title="Non letti"
value={kpi!.messaggi_non_letti}
icon={<MailOpen className="h-5 w-5 text-purple-600" />}
color="bg-purple-50"
/>
</div>
{/* ── Grafici ── */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Grafico a barre (2/3) */}
<div className="xl:col-span-2 bg-white rounded-xl border p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">
Attivita PEC ultimi {days} giorni
</h2>
{chartData.length === 0 ? (
<div className="h-52 flex items-center justify-center text-gray-400 text-sm">
Nessun dato nel periodo selezionato
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={chartData}
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="giorno"
tick={{ fontSize: 11, fill: '#6b7280' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: '#6b7280' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip content={<CustomBarTooltip />} />
<Legend
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
/>
<Bar
dataKey="Ricevute"
fill="#3b82f6"
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
<Bar
dataKey="Inviate"
fill="#8b5cf6"
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Grafico a torta (1/3) */}
<div className="bg-white rounded-xl border p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">
Stato messaggi outbound
</h2>
{pieData.length === 0 ? (
<div className="h-52 flex items-center justify-center text-gray-400 text-sm">
Nessun messaggio outbound
</div>
) : (
<>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={45}
outerRadius={72}
paddingAngle={2}
dataKey="value"
>
{pieData.map((entry, index) => (
<Cell key={index} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomPieTooltip />} />
</PieChart>
</ResponsiveContainer>
{/* Legenda manuale */}
<div className="mt-2 space-y-1">
{pieData.map((entry) => (
<div key={entry.name} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-gray-600">
{STATE_LABELS[entry.name] ?? entry.name}
</span>
</div>
<span className="font-semibold text-gray-800">{entry.value}</span>
</div>
))}
</div>
</>
)}
</div>
</div>
{/* ── Tabella caselle ── */}
{data.mailbox_stats.length > 0 && (
<div className="bg-white rounded-xl border">
<div className="px-5 py-4 border-b">
<h2 className="text-base font-semibold text-gray-800">
Dettaglio per casella
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b">
<th className="text-left px-5 py-3 font-semibold text-gray-600">Casella</th>
<th className="text-center px-4 py-3 font-semibold text-gray-600">Stato</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Ricevute</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Inviate</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Anomalie</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Non letti</th>
<th className="text-right px-5 py-3 font-semibold text-gray-600">Ultima sync</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.mailbox_stats.map((mb) => (
<tr key={mb.mailbox_id} className="hover:bg-gray-50 transition-colors">
<td className="px-5 py-3">
<div className="font-medium text-gray-900 truncate max-w-xs">
{mb.display_name || mb.email_address}
</div>
{mb.display_name && (
<div className="text-xs text-gray-400">{mb.email_address}</div>
)}
</td>
<td className="px-4 py-3 text-center">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
STATUS_BADGE[mb.status] ?? 'bg-gray-100 text-gray-600',
)}
>
{mb.status}
</span>
</td>
<td className="px-4 py-3 text-right font-medium text-gray-900">
{mb.received_total}
</td>
<td className="px-4 py-3 text-right font-medium text-gray-900">
{mb.sent_total}
</td>
<td className="px-4 py-3 text-right">
{mb.anomalie > 0 ? (
<span className="font-semibold text-red-600">{mb.anomalie}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
<td className="px-4 py-3 text-right">
{mb.non_letti > 0 ? (
<span className="font-semibold text-blue-600">{mb.non_letti}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
<td className="px-5 py-3 text-right text-gray-500 text-xs">
{mb.last_sync_at
? format(parseISO(mb.last_sync_at), 'dd/MM HH:mm', { locale: it })
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Footer */}
<p className="text-xs text-gray-400 text-center pb-2">
Totale messaggi nel sistema: {kpi!.totale_messaggi.toLocaleString('it-IT')}
</p>
</>
)}
</div>
</div>
)
}
@@ -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<ConditionField, string> = {
from_address: 'Mittente',
to_address: 'Destinatario',
subject: 'Oggetto',
mailbox_id: 'ID Casella',
pec_type: 'Tipo PEC',
}
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
contains: 'contiene',
not_contains: 'non contiene',
equals: 'uguale a',
starts_with: 'inizia per',
ends_with: 'finisce per',
regex: 'regex',
}
const ACTION_LABELS: Record<ActionType, string> = {
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<RoutingRuleResponse | null>(null)
const [expandedRules, setExpandedRules] = useState<Set<string>>(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<Condition[]>([
{ field: 'from_address', operator: 'contains', value: '' }
])
const [formActions, setFormActions] = useState<Action[]>([
{ 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 (
<div className="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<Settings2 className="h-5 w-5 text-primary" />
Regole di smistamento
</h1>
<p className="text-sm text-muted-foreground">
Applica automaticamente etichette e azioni ai messaggi in arrivo
</p>
</div>
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuova regola
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Settings2 className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessuna regola configurata</p>
<p className="text-sm mt-1">Le regole vengono valutate in ordine di priorita' su ogni messaggio inbound.</p>
</div>
) : (
items.map((rule) => (
<div key={rule.id} className={cn('rounded-lg border bg-card', !rule.is_active && 'opacity-60')}>
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{rule.name}</span>
<span className="text-xs text-muted-foreground">P:{rule.priority}</span>
<span className={cn('text-xs px-2 py-0.5 rounded-full', rule.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600')}>
{rule.is_active ? 'Attiva' : 'Inattiva'}
</span>
{rule.stop_processing && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700">Stop</span>
)}
</div>
{rule.description && <p className="text-xs text-muted-foreground mt-0.5">{rule.description}</p>}
<p className="text-xs text-muted-foreground mt-0.5">
{rule.conditions.length} condizioni / {rule.actions.length} azioni
</p>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(rule)} title="Modifica">
<Settings2 className="h-4 w-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
onClick={() => toggleMutation.mutate(rule.id)}
title={rule.is_active ? 'Disattiva' : 'Attiva'}
>
{rule.is_active
? <XCircle className="h-4 w-4 text-amber-500" />
: <CheckCircle className="h-4 w-4 text-green-500" />
}
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
onClick={() => { if (confirm(`Eliminare la regola "${rule.name}"?`)) deleteMutation.mutate(rule.id) }}
title="Elimina"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toggleExpand(rule.id)}>
{expandedRules.has(rule.id) ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
</div>
{expandedRules.has(rule.id) && (
<div className="border-t px-4 py-3 space-y-3">
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">Condizioni (AND)</p>
{rule.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs bg-muted/40 rounded px-3 py-1.5 mb-1">
<span className="font-medium">{FIELD_LABELS[c.field as ConditionField] ?? c.field}</span>
<span className="text-muted-foreground">{OPERATOR_LABELS[c.operator as ConditionOperator] ?? c.operator}</span>
<span className="font-mono bg-background border rounded px-1.5 py-0.5">{c.value}</span>
</div>
))}
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">Azioni</p>
{rule.actions.map((a, i) => (
<div key={i} className="flex items-center gap-2 text-xs bg-blue-50 rounded px-3 py-1.5 mb-1">
<span className="font-medium text-blue-700">{ACTION_LABELS[a.action_type as ActionType] ?? a.action_type}</span>
{a.action_value && <span className="font-mono text-blue-600">{a.action_value}</span>}
</div>
))}
</div>
</div>
)}
</div>
))
)}
</div>
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica regola' : 'Nuova regola di smistamento'}</DialogTitle>
</DialogHeader>
<div className="space-y-5 mt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 col-span-2">
<Label>Nome *</Label>
<Input value={formName} onChange={e => setFormName(e.target.value)} placeholder="Es. Multe comune Roma" />
</div>
<div className="space-y-2">
<Label>Priorita' (1=alta, 999=bassa)</Label>
<Input type="number" value={formPriority} onChange={e => setFormPriority(e.target.value)} min={1} max={999} />
</div>
<div className="space-y-2">
<Label>Descrizione</Label>
<Input value={formDescription} onChange={e => setFormDescription(e.target.value)} placeholder="Descrizione opzionale" />
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={formStopProcessing} onChange={e => setFormStopProcessing(e.target.checked)} className="rounded" />
<span className="text-sm">Interrompi elaborazione dopo questa regola (stop processing)</span>
</label>
{/* Condizioni */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Condizioni (tutte devono essere soddisfatte - AND)</Label>
<Button type="button" variant="outline" size="sm" onClick={() => setFormConditions(prev => [...prev, { field: 'from_address', operator: 'contains', value: '' }])}>
<Plus className="h-3.5 w-3.5 mr-1" />Aggiungi
</Button>
</div>
{formConditions.map((cond, i) => (
<div key={i} className="flex items-center gap-2 p-3 border rounded-lg bg-muted/20">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.field}
onChange={e => 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]) => <option key={v} value={v}>{l}</option>)}
</select>
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.operator}
onChange={e => 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]) => <option key={v} value={v}>{l}</option>)}
</select>
<Input
className="flex-1"
value={cond.value}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
placeholder="Valore..."
/>
<button type="button" onClick={() => setFormConditions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
{/* Azioni */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Azioni da eseguire</Label>
<Button type="button" variant="outline" size="sm" onClick={() => setFormActions(prev => [...prev, { action_type: 'mark_read', action_value: '' }])}>
<Plus className="h-3.5 w-3.5 mr-1" />Aggiungi
</Button>
</div>
{formActions.map((action, i) => (
<div key={i} className="flex items-center gap-2 p-3 border rounded-lg bg-blue-50">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_type}
onChange={e => 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]) => <option key={v} value={v}>{l}</option>)}
</select>
{(action.action_type === 'apply_label') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona etichetta --</option>
{labels.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
)}
{(action.action_type === 'notify_webhook') && (
<Input
className="flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
placeholder="https://..."
/>
)}
<button type="button" onClick={() => setFormActions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending}>
{editing ? 'Salva modifiche' : 'Crea regola'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+556
View File
@@ -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<string, string> = {}
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<HTMLInputElement>) => {
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 (
<div className="flex flex-col h-full">
{/* ── Header ricerca ── */}
<div className="border-b bg-background px-6 py-4">
<div className="mb-4">
<h1 className="text-xl font-semibold flex items-center gap-2">
<Search className="h-5 w-5 text-primary" />
Ricerca avanzata
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Ricerca full-text su oggetto, corpo del messaggio e allegati PDF/DOCX
</p>
</div>
{/* Barra di ricerca principale */}
<div className="flex gap-2 mb-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder='Cerca in tutti i messaggi… (es. "fattura gennaio" -spam)'
className="pl-9 h-10 text-sm"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
{searchInput && (
<button
onClick={() => { setSearchInput(''); setCommittedSearch('') }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Button onClick={handleSearch} className="h-10 px-5">
<Search className="h-4 w-4 mr-2" />
Cerca
</Button>
<Button
variant="outline"
className={cn('h-10', activeFiltersCount > 0 && 'border-primary text-primary')}
onClick={() => setShowFilters(!showFilters)}
>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Filtri
{activeFiltersCount > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary text-white text-xs font-bold">
{activeFiltersCount}
</span>
)}
{showFilters ? <ChevronUp className="h-3.5 w-3.5 ml-2" /> : <ChevronDown className="h-3.5 w-3.5 ml-2" />}
</Button>
</div>
{/* Pannello filtri avanzati */}
{showFilters && (
<div className="bg-muted/40 rounded-lg border p-4 space-y-3">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{/* Data da */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Data da
</label>
<input
type="date"
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
{/* Data a */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Data a
</label>
<input
type="date"
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
{/* Direzione */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Direzione
</label>
<select
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={direction}
onChange={(e) => setDirection(e.target.value)}
>
{DIRECTIONS.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
</div>
{/* Stato PEC */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Stato PEC
</label>
<select
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={state}
onChange={(e) => setState(e.target.value)}
>
{PEC_STATES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
{/* Tipo PEC */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Tipo PEC
</label>
<select
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={pecType}
onChange={(e) => setPecType(e.target.value)}
>
{PEC_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
{/* Casella */}
{mailboxes.length > 0 && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Casella PEC
</label>
<select
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={mailboxId}
onChange={(e) => setMailboxId(e.target.value)}
>
<option value="">Tutte le caselle</option>
{mailboxes.map((m) => (
<option key={m.id} value={m.id}>
{m.display_name || m.email_address}
</option>
))}
</select>
</div>
)}
</div>
{/* Pulsante reset filtri */}
{activeFiltersCount > 0 && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={handleClearAll} className="text-xs text-muted-foreground">
<X className="h-3.5 w-3.5 mr-1" />
Azzera tutti i filtri
</Button>
</div>
)}
</div>
)}
</div>
{/* ── Risultati ── */}
<div className="flex-1 overflow-y-auto">
{!hasQuery ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Search className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground font-medium">Inizia a cercare</p>
<p className="text-sm text-muted-foreground/70 mt-1 max-w-xs">
Digita un termine nella barra di ricerca o usa i filtri avanzati per trovare i messaggi
</p>
</div>
) : isLoading || isFetching ? (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Ricerca in corso</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Search className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground font-medium">Nessun risultato</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Prova a modificare i termini di ricerca o i filtri
</p>
{hasQuery && (
<Button variant="ghost" size="sm" className="mt-3" onClick={handleClearAll}>
<X className="h-3.5 w-3.5 mr-1" />
Azzera ricerca
</Button>
)}
</div>
) : (
<>
{/* Intestazione risultati */}
<div className="px-6 py-2.5 bg-muted/20 border-b flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{total}</span>{' '}
{total === 1 ? 'risultato trovato' : 'risultati trovati'}
{committedSearch && (
<> per <span className="font-medium text-foreground italic">"{committedSearch}"</span></>
)}
</p>
{(isFetching) && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
</div>
{/* Lista risultati */}
<div className="divide-y">
{messages.map((message) => (
<SearchResultRow
key={message.id}
message={message}
searchTerm={committedSearch}
mailboxName={
mailboxes.find((m) => m.id === message.mailbox_id)?.email_address
}
onClick={() => handleMessageClick(message)}
/>
))}
</div>
</>
)}
</div>
{/* ── Paginazione ── */}
{totalPages > 1 && (
<div className="border-t px-6 py-3 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Pagina {page} di {totalPages} ({total} risultati)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}
// ─── 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)}
<mark className="bg-yellow-200 dark:bg-yellow-800/60 rounded px-0.5 font-medium">
{snippet.slice(snipIdx, snipIdx + term.length)}
</mark>
{snippet.slice(snipIdx + term.length)}
</>
)
}
return (
<div
className={cn(
'flex items-start gap-3 px-6 py-3.5 cursor-pointer hover:bg-muted/50 transition-colors',
isUnread && 'bg-blue-50/50 dark:bg-blue-950/20',
)}
onClick={onClick}
>
{/* Icona direzione */}
<div className="flex-shrink-0 mt-0.5">
{message.direction === 'inbound' ? (
isUnread ? (
<Mail className="h-5 w-5 text-blue-600" />
) : (
<MailOpen className="h-5 w-5 text-muted-foreground" />
)
) : (
<Send className="h-5 w-5 text-muted-foreground" />
)}
</div>
{/* Contenuto */}
<div className="flex-1 min-w-0">
{/* Riga 1: mittente + badge casella + stato + data */}
<div className="flex items-center justify-between gap-2 mb-0.5">
<div className="flex items-center gap-2 min-w-0">
<span className={cn(
'text-sm truncate',
isUnread ? 'font-semibold' : 'font-medium',
)}>
{message.direction === 'inbound'
? (message.from_address || 'Mittente sconosciuto')
: (message.to_addresses?.[0] || 'Destinatario sconosciuto')}
</span>
{mailboxName && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded flex-shrink-0">
{mailboxName}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<PecStateBadge state={message.state} />
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatRelative(message.received_at || message.sent_at || message.created_at)}
</span>
</div>
</div>
{/* Riga 2: oggetto */}
<div className="flex items-center gap-1.5 mb-0.5">
{isUnread && <span className="h-2 w-2 rounded-full bg-blue-600 flex-shrink-0" />}
<p className={cn(
'text-sm',
isUnread ? 'font-medium text-foreground' : 'text-foreground',
)}>
{searchTerm
? highlight(message.subject || '(nessun oggetto)', searchTerm)
: truncate(message.subject || '(nessun oggetto)', 100)}
</p>
</div>
{/* Riga 3: snippet corpo */}
{message.body_text && (
<p className="text-xs text-muted-foreground line-clamp-2">
{searchTerm ? highlight(message.body_text, searchTerm) : truncate(message.body_text, 150)}
</p>
)}
{/* Tag */}
{message.labels && message.labels.length > 0 && (
<div className="mt-1">
<TagBadgeList labels={message.labels} maxVisible={4} size="sm" />
</div>
)}
</div>
{/* Indicatore allegati */}
{message.has_attachments && (
<span className="text-xs text-muted-foreground flex-shrink-0 mt-1"></span>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -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<TemplateResponse | null>(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 (
<div className="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">Template messaggi</h1>
<p className="text-sm text-muted-foreground">
Template riutilizzabili per la composizione PEC
</p>
</div>
{isAdmin && (
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuovo template
</Button>
)}
</div>
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
{/* Ricerca */}
<div className="relative w-full max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca per nome..."
value={q}
onChange={(e) => setQ(e.target.value)}
className="pl-9"
/>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessun template trovato</p>
<p className="text-sm mt-1">
{isAdmin ? 'Crea il tuo primo template con il pulsante in alto.' : 'Nessun template disponibile.'}
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((t) => (
<div key={t.id} className="rounded-lg border bg-card p-4 space-y-2 hover:shadow-sm transition-shadow">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-semibold truncate">{t.name}</h3>
{t.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{t.description}</p>
)}
</div>
{isAdmin && (
<div className="flex gap-1 flex-shrink-0">
<Button variant="ghost" size="icon" onClick={() => openEdit(t)} className="h-8 w-8">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => {
if (confirm(`Eliminare il template "${t.name}"?`)) {
deleteMutation.mutate(t.id)
}
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
{t.subject && (
<p className="text-sm font-medium text-foreground/80 truncate">
Oggetto: {t.subject}
</p>
)}
<p className="text-xs text-muted-foreground">
Aggiornato: {formatDate(t.updated_at)}
</p>
</div>
))}
</div>
)}
</div>
{/* Dialog form */}
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica template' : 'Nuovo template'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Nome *</Label>
<Input value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="Es. Risposta a ricorso" />
</div>
<div className="space-y-2">
<Label>Descrizione (opzionale)</Label>
<Input value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Breve descrizione del template" />
</div>
<div className="space-y-2">
<Label>Oggetto predefinito</Label>
<Input value={formSubject} onChange={(e) => setFormSubject(e.target.value)} placeholder="Oggetto del messaggio PEC" />
</div>
<div className="space-y-2">
<Label>Corpo del messaggio</Label>
<div className="min-h-[200px] border rounded-md overflow-hidden">
<RichTextEditor value={formBody} onChange={setFormBody} placeholder="Testo del template..." />
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending}>
{editing ? 'Salva modifiche' : 'Crea template'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+17
View File
@@ -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<T> {
items: T[]
total: number
page: number
page_size: number
pages: number
}
export interface PaginationParams {
page?: number
page_size?: number
+4
View File
@@ -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
+55 -5
View File
@@ -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
+234
View File
@@ -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}")
+666
View File
@@ -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"<b>Nuovo messaggio PEC</b>\n\n"
f"<b>Tipo:</b> {tipo_label}\n"
f"<b>Da:</b> {from_addr}\n"
f"<b>Casella:</b> {mailbox_email}\n"
f"<b>Oggetto:</b> {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,
}
+718
View File
@@ -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"
)
+3 -1
View File
@@ -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
+127 -1
View File
@@ -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"
)
+1
View File
@@ -0,0 +1 @@
# Senders notifiche multi-canale per il worker
+76
View File
@@ -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
+69
View File
@@ -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", {})
+74
View File
@@ -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,
}
+73
View File
@@ -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}

Some files were not shown because too many files have changed in this diff Show More