Semantic search

This commit is contained in:
2026-03-25 18:39:50 +01:00
parent f5fb537fed
commit cbeedc2d2f
14 changed files with 1336 additions and 56 deletions
-38
View File
@@ -888,44 +888,6 @@ END $$;
## 4. Decisioni Architetturali ## 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 ### ADR-002 Cifratura credenziali IMAP/SMTP a riposo
+148
View File
@@ -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 (00010007)
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'.
@@ -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')
+41 -11
View File
@@ -26,6 +26,8 @@ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.services.search_service import SearchService
from app.config import get_settings from app.config import get_settings
from app.core.exceptions import ForbiddenError, NotFoundError from app.core.exceptions import ForbiddenError, NotFoundError
from app.database import get_db from app.database import get_db
@@ -184,8 +186,11 @@ async def list_messages(
is_starred: Optional[bool] = Query(None), is_starred: Optional[bool] = Query(None),
is_archived: Optional[bool] = Query(False), is_archived: Optional[bool] = Query(False),
is_trashed: 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), 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 # Paginazione
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200), 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_archived=False` (default) esclude i messaggi archiviati.
- `is_trashed=False` (default) esclude i messaggi nel cestino. - `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. - `vbox_id` filtra per Virtual Box assegnata all'utente corrente.
""" """
# Determinare le caselle visibili (normale check permessi) # Determinare le caselle visibili (normale check permessi)
@@ -284,16 +290,30 @@ async def list_messages(
if is_trashed is not None: if is_trashed is not None:
q = q.where(Message.is_trashed == is_trashed) q = q.where(Message.is_trashed == is_trashed)
# ── Full-text search (FTS con fallback ILIKE per messaggi non indicizzati) ───
if 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( q = q.where(
or_( or_(
Message.subject.ilike(term), Message.search_vector.op("@@")(tsquery),
Message.from_address.ilike(term), # Fallback per messaggi non ancora indicizzati dal worker
Message.body_text.ilike(term), 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) # Applica le regole della Virtual Box (AND tra le regole)
for rule in vbox_rules: for rule in vbox_rules:
q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value) 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()) count_q = select(func.count()).select_from(q.subquery())
total = (await db.execute(count_q)).scalar_one() 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 = (
q.options(selectinload(Message.labels)) q.options(selectinload(Message.labels))
.order_by( .order_by(*order_clauses)
Message.received_at.desc().nullslast(),
Message.created_at.desc(),
)
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
.limit(page_size) .limit(page_size)
) )
+8 -1
View File
@@ -4,6 +4,7 @@ Modelli Message, Attachment, SendJob.
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Any
from sqlalchemy import ( from sqlalchemy import (
ARRAY, ARRAY,
@@ -18,7 +19,7 @@ from sqlalchemy import (
Text, Text,
func, func,
) )
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import TSVECTOR, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -96,6 +97,9 @@ class Message(Base):
raw_eml_path: Mapped[str | None] = mapped_column(Text, 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
@@ -126,6 +130,7 @@ class Message(Base):
postgresql_where="parent_message_id IS NOT NULL", postgresql_where="parent_message_id IS NOT NULL",
), ),
Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"), Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"),
Index("idx_messages_fts", "search_vector", postgresql_using="gin"),
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@@ -149,6 +154,8 @@ class Attachment(Base):
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True) size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
storage_path: Mapped[str] = mapped_column(Text, nullable=False) storage_path: Mapped[str] = mapped_column(Text, nullable=False)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
+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
+4
View File
@@ -11,6 +11,7 @@ import { SettingsPage } from '@/pages/Settings/SettingsPage'
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage' import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage' import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage' import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
import { SearchPage } from '@/pages/Search/SearchPage'
/** /**
* Routing principale dell'applicazione PEChub. * Routing principale dell'applicazione PEChub.
@@ -76,6 +77,9 @@ export default function App() {
{/* Super Admin Gestione Multi-Tenant */} {/* Super Admin Gestione Multi-Tenant */}
<Route path="/multitenant" element={<MultiTenantPage />} /> <Route path="/multitenant" element={<MultiTenantPage />} />
{/* Ricerca avanzata full-text */}
<Route path="/search" element={<SearchPage />} />
{/* Profilo utente */} {/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
+5
View File
@@ -13,11 +13,16 @@ export interface MessageFilters {
mailbox_id?: string mailbox_id?: string
direction?: 'inbound' | 'outbound' direction?: 'inbound' | 'outbound'
state?: string state?: string
pec_type?: string
is_read?: boolean is_read?: boolean
is_starred?: boolean is_starred?: boolean
is_archived?: boolean is_archived?: boolean
is_trashed?: boolean is_trashed?: boolean
search?: string 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 { export interface MessageBulkUpdatePayload {
@@ -50,6 +50,7 @@ import {
Archive, Archive,
Building2, Building2,
Trash2, Trash2,
Search,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
@@ -343,6 +344,29 @@ export function Sidebar() {
</div> </div>
)} )}
{/* ── Ricerca avanzata ── */}
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2">
<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>
</div>
</div>
{/* ── Nuova PEC ── */} {/* ── Nuova PEC ── */}
<div> <div>
<div className="border-t border-gray-700 mx-4 mb-3" /> <div className="border-t border-gray-700 mx-4 mb-3" />
+106 -6
View File
@@ -36,6 +36,9 @@ import {
Trash2, Trash2,
RotateCcw, RotateCcw,
MailX, MailX,
SlidersHorizontal,
ChevronDown,
ChevronUp,
} from 'lucide-react' } from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@@ -80,6 +83,14 @@ export function InboxPage({ viewMode }: InboxPageProps) {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const PAGE_SIZE = 50 const PAGE_SIZE = 50
// ── Filtri avanzati ──────────────────────────────────────────────────────────
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [pecTypeFilter, setPecTypeFilter] = useState('')
const activeAdvancedFiltersCount = [dateFrom, dateTo, pecTypeFilter].filter(Boolean).length
// ── Stato selezione ───────────────────────────────────────────────────────── // ── Stato selezione ─────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -91,6 +102,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
setDebouncedSearch('') setDebouncedSearch('')
setIsReadFilter(undefined) setIsReadFilter(undefined)
setIsStarredFilter(undefined) setIsStarredFilter(undefined)
setDateFrom('')
setDateTo('')
setPecTypeFilter('')
setShowAdvancedFilters(false)
setPage(1) setPage(1)
setSelectedIds(new Set()) setSelectedIds(new Set())
}, [mailboxId, vboxId, viewMode]) }, [mailboxId, vboxId, viewMode])
@@ -125,19 +140,27 @@ export function InboxPage({ viewMode }: InboxPageProps) {
? myVboxes.find((v) => v.id === vboxId) ? myVboxes.find((v) => v.id === vboxId)
: undefined : undefined
// ── Reset pagina per filtri avanzati ─────────────────────────────────────────
useEffect(() => {
setPage(1)
}, [dateFrom, dateTo, pecTypeFilter])
// ── Query messaggi ─────────────────────────────────────────────────────────── // ── Query messaggi ───────────────────────────────────────────────────────────
const queryFilters = (() => { const queryFilters = (() => {
const base = { const advancedBase = {
vbox_id: vboxId, vbox_id: vboxId,
mailbox_id: mailboxId, mailbox_id: mailboxId,
search: debouncedSearch || undefined, search: debouncedSearch || undefined,
pec_type: pecTypeFilter || undefined,
date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined,
page, page,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
} }
switch (viewMode) { switch (viewMode) {
case 'inbox': case 'inbox':
return { return {
...base, ...advancedBase,
direction: 'inbound' as const, direction: 'inbound' as const,
is_read: isReadFilter, is_read: isReadFilter,
is_starred: isStarredFilter, is_starred: isStarredFilter,
@@ -146,7 +169,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
} }
case 'sent': case 'sent':
return { return {
...base, ...advancedBase,
direction: 'outbound' as const, direction: 'outbound' as const,
is_starred: isStarredFilter, is_starred: isStarredFilter,
is_archived: false, is_archived: false,
@@ -154,20 +177,20 @@ export function InboxPage({ viewMode }: InboxPageProps) {
} }
case 'starred': case 'starred':
return { return {
...base, ...advancedBase,
is_starred: true, is_starred: true,
is_archived: false, is_archived: false,
is_trashed: false, is_trashed: false,
} }
case 'archived': case 'archived':
return { return {
...base, ...advancedBase,
is_archived: true, is_archived: true,
is_trashed: false, is_trashed: false,
} }
case 'trash': case 'trash':
return { return {
...base, ...advancedBase,
is_trashed: true, is_trashed: true,
} }
} }
@@ -499,7 +522,84 @@ export function InboxPage({ viewMode }: InboxPageProps) {
Preferiti Preferiti
</Button> </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> </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>
{/* Reset filtri */}
{activeAdvancedFiltersCount > 0 && (
<div className="flex items-end">
<Button
variant="ghost"
size="sm"
className="h-8 text-xs text-muted-foreground w-full"
onClick={() => { setDateFrom(''); setDateTo(''); setPecTypeFilter('') }}
>
<X className="h-3.5 w-3.5 mr-1" />
Azzera
</Button>
</div>
)}
</div>
)}
</div> </div>
{/* ── Barra azioni bulk ── */} {/* ── Barra azioni bulk ── */}
+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>
)
}
+7
View File
@@ -35,6 +35,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings from app.config import get_settings
from app.jobs.index_message import index_message
from app.models import Attachment, Mailbox, Message from app.models import Attachment, Mailbox, Message
from app.parsers.eml_parser import parse_eml from app.parsers.eml_parser import parse_eml
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message
@@ -635,6 +636,12 @@ async def _save_message(
f"direction={direction!r} pec_type={pec_class.pec_type!r} " f"direction={direction!r} pec_type={pec_class.pec_type!r} "
f"subject={message.subject!r} allegati={len(parsed.attachments)}" 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)
return True return True
+218
View File
@@ -0,0 +1,218 @@
"""
Indicizzazione full-text dei messaggi PEC.
Responsabilita':
1. Scarica gli allegati PDF e DOCX da MinIO
2. Estrae il testo con pypdf (PDF) e python-docx (DOCX)
3. Aggiorna la colonna extracted_text in attachments
4. Aggiorna la colonna search_vector in messages includendo il testo degli allegati
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 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
# ─── Estrazione testo ─────────────────────────────────────────────────────────
def _extract_pdf_text(content: bytes) -> str:
"""Estrae testo da un PDF usando pypdf."""
try:
import pypdf # type: ignore[import]
reader = pypdf.PdfReader(io.BytesIO(content))
parts: list[str] = []
for page in reader.pages:
try:
txt = page.extract_text()
if txt:
parts.append(txt)
except Exception:
continue
return " ".join(parts)
except ImportError:
logger.warning("pypdf non installato: impossibile estrarre testo da PDF")
return ""
except Exception as e:
logger.debug(f"Errore estrazione testo PDF: {e}")
return ""
def _extract_docx_text(content: bytes) -> str:
"""Estrae testo da un DOCX usando python-docx."""
try:
import docx # type: ignore[import]
doc = docx.Document(io.BytesIO(content))
parts = [para.text for para in doc.paragraphs if para.text and para.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 testo DOCX: {e}")
return ""
def _is_pdf(content_type: str | None, filename: str | None) -> bool:
ct = (content_type or "").lower()
fn = (filename or "").lower()
return ct == "application/pdf" or fn.endswith(".pdf")
def _is_docx(content_type: str | None, filename: str | None) -> bool:
ct = (content_type or "").lower()
fn = (filename or "").lower()
return ct in (
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"application/vnd.ms-word",
) or fn.endswith((".docx", ".doc"))
# ─── 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 e' un PDF o DOCX
if not (_is_pdf(att.content_type, att.filename) or _is_docx(att.content_type, att.filename)):
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
if _is_pdf(att.content_type, att.filename):
extracted = _extract_pdf_text(content)
else:
extracted = _extract_docx_text(content)
if not extracted or not extracted.strip():
continue
# Limita la dimensione e salva
att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN]
attachment_texts.append(att.extracted_text)
indexed_count += 1
# ── 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 PDF/DOCX con testo estraibile"
)
+4
View File
@@ -41,6 +41,10 @@ dependencies = [
# Utilities # Utilities
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"email-validator>=2.2.0", "email-validator>=2.2.0",
# Full-text search: estrazione testo da allegati PDF e DOCX
"pypdf>=4.0.0",
"python-docx>=1.1.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]