mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Semantic search
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+148
@@ -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'.
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 */}
|
||||
<Route path="/multitenant" element={<MultiTenantPage />} />
|
||||
|
||||
{/* Ricerca avanzata full-text */}
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
|
||||
{/* Profilo utente */}
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
|
||||
|
||||
@@ -13,11 +13,16 @@ 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
|
||||
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 {
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
Archive,
|
||||
Building2,
|
||||
Trash2,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
@@ -343,6 +344,29 @@ export function Sidebar() {
|
||||
</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 ── */}
|
||||
<div>
|
||||
<div className="border-t border-gray-700 mx-4 mb-3" />
|
||||
|
||||
@@ -36,6 +36,9 @@ import {
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
MailX,
|
||||
SlidersHorizontal,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
@@ -80,6 +83,14 @@ 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 activeAdvancedFiltersCount = [dateFrom, dateTo, pecTypeFilter].filter(Boolean).length
|
||||
|
||||
// ── Stato selezione ─────────────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
@@ -91,6 +102,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
setDebouncedSearch('')
|
||||
setIsReadFilter(undefined)
|
||||
setIsStarredFilter(undefined)
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
setPecTypeFilter('')
|
||||
setShowAdvancedFilters(false)
|
||||
setPage(1)
|
||||
setSelectedIds(new Set())
|
||||
}, [mailboxId, vboxId, viewMode])
|
||||
@@ -125,19 +140,27 @@ 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,
|
||||
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,
|
||||
@@ -146,7 +169,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
}
|
||||
case 'sent':
|
||||
return {
|
||||
...base,
|
||||
...advancedBase,
|
||||
direction: 'outbound' as const,
|
||||
is_starred: isStarredFilter,
|
||||
is_archived: false,
|
||||
@@ -154,20 +177,20 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
}
|
||||
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 {
|
||||
...base,
|
||||
...advancedBase,
|
||||
is_trashed: true,
|
||||
}
|
||||
}
|
||||
@@ -499,7 +522,84 @@ 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* ── Barra azioni bulk ── */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
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
|
||||
@@ -635,6 +636,12 @@ 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)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -41,6 +41,10 @@ dependencies = [
|
||||
# Utilities
|
||||
"python-dotenv>=1.0.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]
|
||||
|
||||
Reference in New Issue
Block a user