diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index e288c78..0941f76 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -888,44 +888,6 @@ END $$;
## 4. Decisioni Architetturali
----
-
-### ADR-001 – Multi-tenancy: Schema-per-tenant vs Row-level con tenant_id
-
-**Opzione A – Schema-per-tenant (un PostgreSQL schema per ogni organizzazione)**
-
-*Pro:*
-- Isolamento totale dei dati: impossibile data leak cross-tenant per bug SQL
-- Backup e restore per singolo tenant molto semplici
-- Possibilità di migrare un tenant su un DB separato senza refactoring
-
-*Contro:*
-- Alembic migrations vanno applicate a tutti gli schema (N schema × migrazione)
-- Connection pooling complesso: PgBouncer deve gestire schema switching
-- Difficile fare query aggregate cross-tenant (es. monitoraggio globale SaaS)
-- Overhead operativo significativo oltre i 100 tenant
-
-**Opzione B – Row-level con `tenant_id` + PostgreSQL RLS**
-
-*Pro:*
-- Un solo schema, migrations applicate una volta
-- Query cross-tenant per operazioni di sistema (monitoring, billing)
-- Molto più semplice da gestire con Alembic e ORM
-- Scala bene fino a decine di migliaia di tenant
-
-*Contro:*
-- Bug nell'impostazione `current_tenant_id` può causare data leak → mitigato con RLS come secondo livello
-- Restore di singolo tenant richiede WHERE clause su dump
-
-**Raccomandazione: Opzione B (row-level + RLS)**
-
-Per un SaaS B2B con caselle PEC, il numero di tenant nel medio termine è nell'ordine delle centinaia, non decine di migliaia. Il rischio principale (data leak cross-tenant) è mitigato da due strati indipendenti:
-1. Applicativo: ogni query include `WHERE tenant_id = :current_tenant_id`
-2. DB: PostgreSQL RLS come safety net, impostata tramite `SET LOCAL app.current_tenant_id = '...'` in ogni transazione
-
-Il vantaggio operativo (migrazioni semplici, query di monitoring, pool unico) supera i rischi, a condizione di avere test di integrazione che verificano il corretto isolamento.
-
----
### ADR-002 – Cifratura credenziali IMAP/SMTP a riposo
diff --git a/GapAnalysis.md b/GapAnalysis.md
new file mode 100644
index 0000000..7f53277
--- /dev/null
+++ b/GapAnalysis.md
@@ -0,0 +1,148 @@
+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
+1. Dispatch automatico notifiche (Sistema di notifiche incompleto al 60%)
+
+Il CRUD canali/regole/log e' implementato, ma manca tutto il lato dispatch
+Non esiste worker/app/jobs/dispatch_notification.py
+NotificationService non ha il metodo evaluate_rules(event_type, message) che valuta le regole e accoda i job
+L'IMAP sync (sync.py) non chiama nulla al salvataggio di un nuovo messaggio
+Il test canale webhook e email e' uno stub che restituisce sempre successo (solo Telegram ha invio reale)
+La cifratura in notification_service.py usa base64 grezzo, non AES-256-GCM: i segreti (bot_token, webhook_secret, smtp_password) sono leggibili in chiaro nel DB
+Canale WhatsApp: nessuna implementazione reale (stub completo)
+Canale Email SMTP: nessuna implementazione reale (stub completo)
+Risultato pratico: le notifiche sono configurabili ma non vengono mai inviate automaticamente
+2. Ricerca avanzata full-text (Fase 5-B – completamente mancante)
+
+Non esiste backend/app/api/v1/search.py
+Non esiste backend/app/services/search_service.py
+Non esiste frontend/src/pages/Search/ ne' frontend/src/api/search.api.ts
+La "ricerca" nell'InboxPage usa solo ILIKE su subject/from_address/body_text: e' lenta su volumi grandi e non cerca nel testo degli allegati
+La colonna search_vector tsvector non e' nelle migrazioni Alembic attuali (0001–0007)
+Non c'e' Apache Tika e non c'e' worker/app/jobs/index_message.py per l'estrazione testo da PDF/DOCX
+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
+4. Dashboard e Reportistica (Fase 7 – completamente mancante)
+
+Non esistono endpoint /reports/summary, /reports/export
+Non esiste pagina Reports/Dashboard nel frontend (nessuna rotta in App.tsx)
+Non c'e' generazione PDF (WeasyPrint) ne' export CSV
+Non c'e' nessun grafico o KPI visibile (PEC ricevute/inviate oggi, anomalie, tasso consegna)
+5. Audit Log – modello esistente, tutto il resto mancante
+
+Il modello audit_log.py e la tabella esistono
+Non c'e' nessun endpoint API GET /audit-log per leggerlo
+Non c'e' nessuna pagina frontend per la visualizzazione
+Non e' chiaro se il backend registra effettivamente gli eventi (nessuna chiamata a AuditLog trovata nei servizi)
+COSA MANCA – PRIORITA' MEDIA
+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
+8. Invio PEC – funzionalita' mancanti
+
+Non c'e' Forward messaggio (la risposta e' parzialmente implementata in ComposePage ma non e' chiaro se funziona end-to-end)
+Non c'e' endpoint per forzare un re-sync manuale di una casella (utile dopo un errore di connessione)
+Non c'e' indicazione visiva del numero di messaggi non letti nella sidebar per casella
+La barra ricerca nell'Inbox non ha filtri per data (da/a), stato PEC, tipo PEC
+9. Ruolo Supervisor
+
+Il ruolo supervisor e' definito nell'enum DB e nella documentazione ma non ha logica differenziata dal operator nel codice: is_admin controlla solo admin/super_admin, tutto il resto e' trattato uguale
+10. Gestione quote casella
+
+L'evento mailbox.quota_warning e' definito negli enum delle notifiche ma non e' mai generato dal worker (nessuna stima della quota IMAP)
+COSA MANCA – PRIORITA' BASSA (Hardening / Go-Live)
+11. Monitoring e osservabilita'
+
+Non c'e' infra/prometheus/ ne' infra/grafana/ (previsti in ARCHITECTURE.md ma non creati)
+Non c'e' log aggregation (Loki/ELK)
+Non ci sono metriche esposte dal backend (es. /metrics endpoint Prometheus)
+12. Backup automatico
+
+Non c'e' script o cronjob per pg_dump automatico verso MinIO
+13. Test coverage
+
+I test di integrazione esistenti coprono auth, users, send API
+Non ci sono test per: messages API, permissions API, virtual_boxes, notifications, archival
+Non c'e' copertura frontend (nessun test Vitest/Playwright presente)
+14. GDPR
+
+Non c'e' endpoint DELETE /tenants/{id} per cancellazione completa dati tenant con audit trail
+RIEPILOGO STATO PER FASE
+Fase Descrizione Stato
+1 Fondamenta + Auth + Multi-tenancy Completa
+1-A Permessi granulari per casella Completa
+2 IMAP Sync Engine Completa
+3 Parser PEC e Tracking Ricevute Completa
+4 Invio SMTP con retry Completa
+5 Frontend base (inbox, compose, admin) Completa
+5-A Virtual Box Completa
+5-B Ricerca avanzata full-text Non iniziata
+5-C Notifiche multi-canale Struttura pronta, dispatch mancante
+6 Archiviazione sostitutiva ~15% (client mock presente, tutto il resto mancante)
+7 Dashboard e Reportistica Non iniziata
+8 Hardening, test, go-live Parziale (sicurezza base presente, monitoring/backup/CI mancanti)
+PRIORITA' DI INTERVENTO CONSIGLIATA
+Notifiche dispatch – e' la funzionalita' piu' vicina al completamento: la struttura dati e' pronta, manca solo il wiring tra IMAP sync, il servizio di valutazione regole e il job worker. Ha alto impatto operativo.
+Ricerca avanzata – blocca l'usabilita' su volumi di posta significativi. La ricerca ILIKE attuale scala male.
+Archiviazione sostitutiva – obbligatoria per la compliance normativa dei clienti PA/professionisti.
+Fix sicurezza cifratura notifiche – critico: i segreti (bot token, webhook secret) sono attualmente non cifrati nel DB.
+Audit log – necessario per compliance e per l'utilizzo enterprise.
+Dashboard/Report – utile commercialmente ma non bloccante per l'operativita'.
\ No newline at end of file
diff --git a/backend/alembic/versions/0008_full_text_search.py b/backend/alembic/versions/0008_full_text_search.py
new file mode 100644
index 0000000..a173f77
--- /dev/null
+++ b/backend/alembic/versions/0008_full_text_search.py
@@ -0,0 +1,76 @@
+"""add full text search vector to messages and extracted_text to attachments
+
+Revision ID: 0008
+Revises: 0007
+Create Date: 2026-03-25
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision = '0008'
+down_revision = '0007'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # 1. Aggiunge colonna search_vector a messages
+ op.add_column(
+ 'messages',
+ sa.Column('search_vector', postgresql.TSVECTOR(), nullable=True),
+ )
+
+ # 2. Aggiunge colonna extracted_text ad attachments (testo estratto da PDF/DOCX)
+ op.add_column(
+ 'attachments',
+ sa.Column('extracted_text', sa.Text(), nullable=True),
+ )
+
+ # 3. Indice GIN per ricerca full-text veloce
+ op.execute(
+ "CREATE INDEX idx_messages_fts ON messages USING gin(search_vector) "
+ "WHERE search_vector IS NOT NULL"
+ )
+
+ # 4. Funzione trigger che aggiorna search_vector quando cambiano i campi testuali
+ op.execute("""
+ CREATE OR REPLACE FUNCTION messages_search_vector_update() RETURNS trigger AS $$
+ BEGIN
+ NEW.search_vector :=
+ setweight(to_tsvector('italian', coalesce(NEW.subject, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(NEW.from_address, '')), 'B') ||
+ setweight(to_tsvector('simple',
+ coalesce(array_to_string(NEW.to_addresses, ' '), '')), 'B') ||
+ setweight(to_tsvector('italian', coalesce(NEW.body_text, '')), 'C');
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql;
+ """)
+
+ # 5. Crea trigger (si attiva su INSERT e UPDATE dei campi rilevanti)
+ op.execute("""
+ CREATE TRIGGER trg_messages_search_vector
+ BEFORE INSERT OR UPDATE OF subject, from_address, to_addresses, body_text
+ ON messages
+ FOR EACH ROW EXECUTE FUNCTION messages_search_vector_update();
+ """)
+
+ # 6. Backfill: popola search_vector per i messaggi esistenti
+ op.execute("""
+ UPDATE messages SET search_vector =
+ setweight(to_tsvector('italian', coalesce(subject, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') ||
+ setweight(to_tsvector('simple',
+ coalesce(array_to_string(to_addresses, ' '), '')), 'B') ||
+ setweight(to_tsvector('italian', coalesce(body_text, '')), 'C')
+ WHERE search_vector IS NULL
+ """)
+
+
+def downgrade() -> None:
+ op.execute("DROP TRIGGER IF EXISTS trg_messages_search_vector ON messages")
+ op.execute("DROP FUNCTION IF EXISTS messages_search_vector_update()")
+ op.execute("DROP INDEX IF EXISTS idx_messages_fts")
+ op.drop_column('attachments', 'extracted_text')
+ op.drop_column('messages', 'search_vector')
diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py
index 137b3e2..f003ec7 100644
--- a/backend/app/api/v1/messages.py
+++ b/backend/app/api/v1/messages.py
@@ -26,6 +26,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
@@ -184,8 +186,11 @@ async def list_messages(
is_starred: Optional[bool] = Query(None),
is_archived: Optional[bool] = Query(False),
is_trashed: Optional[bool] = Query(False),
- search: Optional[str] = Query(None, max_length=200),
+ search: Optional[str] = Query(None, max_length=500),
pec_type: Optional[str] = Query(None),
+ # Filtri data (ISO 8601, es. 2026-01-01T00:00:00Z)
+ 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)"),
# Paginazione
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
@@ -195,7 +200,8 @@ async def list_messages(
- `is_archived=False` (default) esclude i messaggi archiviati.
- `is_trashed=False` (default) esclude i messaggi nel cestino.
- - `search` cerca su subject, from_address, to_addresses.
+ - `search` usa ricerca full-text (tsvector) con fallback ILIKE.
+ - `date_from` / `date_to` filtrano per data ricezione o invio.
- `vbox_id` filtra per Virtual Box assegnata all'utente corrente.
"""
# Determinare le caselle visibili (normale check permessi)
@@ -284,16 +290,30 @@ async def list_messages(
if is_trashed is not None:
q = q.where(Message.is_trashed == is_trashed)
+ # ── Full-text search (FTS con fallback ILIKE per messaggi non indicizzati) ───
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),
+ # Fallback per messaggi non ancora indicizzati dal worker
+ Message.search_vector.is_(None) & or_(
+ Message.subject.ilike(term_like),
+ Message.from_address.ilike(term_like),
+ Message.body_text.ilike(term_like),
+ ),
)
)
+ # ── 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))
+
# Applica le regole della Virtual Box (AND tra le regole)
for rule in vbox_rules:
q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value)
@@ -302,13 +322,23 @@ async def list_messages(
count_q = select(func.count()).select_from(q.subquery())
total = (await db.execute(count_q)).scalar_one()
- # Ordinamento e paginazione
+ # Ordinamento: se c'e' una ricerca, ordina per rilevanza FTS, poi data
+ 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()]
+
+ # Paginazione
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)
)
diff --git a/backend/app/models/message.py b/backend/app/models/message.py
index b037b01..63506d0 100644
--- a/backend/app/models/message.py
+++ b/backend/app/models/message.py
@@ -4,6 +4,7 @@ Modelli Message, Attachment, SendJob.
import uuid
from datetime import datetime
+from typing import Any
from sqlalchemy import (
ARRAY,
@@ -18,7 +19,7 @@ from sqlalchemy import (
Text,
func,
)
-from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.dialects.postgresql import TSVECTOR, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -96,6 +97,9 @@ class Message(Base):
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()
)
@@ -126,6 +130,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:
@@ -149,6 +154,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()
)
diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py
new file mode 100644
index 0000000..9b9baf6
--- /dev/null
+++ b/backend/app/services/search_service.py
@@ -0,0 +1,139 @@
+"""
+Servizio di ricerca full-text per i messaggi PEC.
+
+Utilizza i vettori tsvector di PostgreSQL per ricerche veloci su:
+ - oggetto (peso A)
+ - mittente / destinatari (peso B)
+ - corpo del messaggio (peso C)
+ - testo estratto dagli allegati PDF/DOCX (peso D)
+
+Se search_vector e' NULL (messaggio non ancora indicizzato dal worker),
+cade back automaticamente a ILIKE sulle colonne base.
+"""
+
+import uuid
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import case, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.models.message import Message
+
+
+class SearchService:
+ """Incapsula la logica di ricerca full-text sui messaggi."""
+
+ def __init__(self, db: AsyncSession) -> None:
+ self.db = db
+
+ async def search_messages(
+ self,
+ tenant_id: uuid.UUID,
+ search_term: str,
+ visible_mailbox_ids: Optional[list[uuid.UUID]],
+ mailbox_id: Optional[uuid.UUID] = None,
+ direction: Optional[str] = None,
+ state: Optional[str] = None,
+ pec_type: Optional[str] = None,
+ date_from: Optional[datetime] = None,
+ date_to: Optional[datetime] = None,
+ is_archived: Optional[bool] = False,
+ is_trashed: Optional[bool] = False,
+ is_starred: Optional[bool] = None,
+ is_read: Optional[bool] = None,
+ page: int = 1,
+ page_size: int = 50,
+ ) -> tuple[list[Message], int]:
+ """
+ Ricerca full-text nei messaggi.
+
+ Logica:
+ 1. Messaggi con search_vector non NULL → usa @@ operator + ts_rank
+ 2. Messaggi con search_vector NULL → fallback ILIKE (non ancora indicizzati)
+ 3. Applica tutti i filtri aggiuntivi (data, stato, tipo, direzione, ecc.)
+ 4. Ordina per rilevanza FTS desc, poi per data desc
+ """
+ q = select(Message).where(
+ Message.tenant_id == tenant_id,
+ Message.parent_message_id.is_(None),
+ )
+
+ # Restrizione caselle visibili (permessi)
+ if visible_mailbox_ids is not None:
+ if not visible_mailbox_ids:
+ return [], 0
+ q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
+
+ # Filtri opzionali
+ if mailbox_id:
+ q = q.where(Message.mailbox_id == mailbox_id)
+ if direction:
+ q = q.where(Message.direction == direction)
+ if state:
+ q = q.where(Message.state == state)
+ if pec_type:
+ q = q.where(Message.pec_type == pec_type)
+ if is_archived is not None:
+ q = q.where(Message.is_archived == is_archived)
+ if is_trashed is not None:
+ q = q.where(Message.is_trashed == is_trashed)
+ if is_starred is not None:
+ q = q.where(Message.is_starred == is_starred)
+ if is_read is not None:
+ q = q.where(Message.is_read == is_read)
+
+ # Filtri data: cerca sia su received_at che su sent_at
+ if date_from:
+ q = q.where(
+ or_(
+ Message.received_at >= date_from,
+ Message.sent_at >= date_from,
+ )
+ )
+ if date_to:
+ q = q.where(
+ or_(
+ Message.received_at <= date_to,
+ Message.sent_at <= date_to,
+ )
+ )
+
+ # Full-text search con fallback ILIKE
+ tsquery = func.websearch_to_tsquery("italian", search_term)
+ term_like = f"%{search_term}%"
+
+ fts_condition = Message.search_vector.op("@@")(tsquery)
+ ilike_fallback = Message.search_vector.is_(None) & or_(
+ Message.subject.ilike(term_like),
+ Message.from_address.ilike(term_like),
+ Message.body_text.ilike(term_like),
+ )
+
+ q = q.where(or_(fts_condition, ilike_fallback))
+
+ # Conteggio totale (senza paginazione)
+ count_q = select(func.count()).select_from(q.subquery())
+ total: int = (await self.db.execute(count_q)).scalar_one()
+
+ # Ordinamento per rilevanza FTS, poi data
+ rank_expr = case(
+ (Message.search_vector.isnot(None), func.ts_rank(Message.search_vector, tsquery)),
+ else_=0.0,
+ )
+
+ q = (
+ q.options(selectinload(Message.labels))
+ .order_by(
+ rank_expr.desc(),
+ Message.received_at.desc().nullslast(),
+ Message.created_at.desc(),
+ )
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+
+ result = await self.db.execute(q)
+ items = list(result.scalars().all())
+ return items, total
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index eda1ddb..b0599fc 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -11,6 +11,7 @@ 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'
/**
* Routing principale dell'applicazione PEChub.
@@ -76,6 +77,9 @@ export default function App() {
{/* Super Admin – Gestione Multi-Tenant */}
+ Ricerca full-text su oggetto, corpo del messaggio e allegati PDF/DOCX +
+Inizia a cercare
++ Digita un termine nella barra di ricerca o usa i filtri avanzati per trovare i messaggi +
+Ricerca in corso…
+Nessun risultato
++ Prova a modificare i termini di ricerca o i filtri +
+ {hasQuery && ( + + )} ++ {total}{' '} + {total === 1 ? 'risultato trovato' : 'risultati trovati'} + {committedSearch && ( + <> per "{committedSearch}"> + )} +
+ {(isFetching) && ( + + )} ++ Pagina {page} di {totalPages} ({total} risultati) +
++ {searchTerm + ? highlight(message.subject || '(nessun oggetto)', searchTerm) + : truncate(message.subject || '(nessun oggetto)', 100)} +
++ {searchTerm ? highlight(message.body_text, searchTerm) : truncate(message.body_text, 150)} +
+ )} + + {/* Tag */} + {message.labels && message.labels.length > 0 && ( +