diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e288c78..0941f76 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -888,44 +888,6 @@ END $$; ## 4. Decisioni Architetturali ---- - -### ADR-001 – Multi-tenancy: Schema-per-tenant vs Row-level con tenant_id - -**Opzione A – Schema-per-tenant (un PostgreSQL schema per ogni organizzazione)** - -*Pro:* -- Isolamento totale dei dati: impossibile data leak cross-tenant per bug SQL -- Backup e restore per singolo tenant molto semplici -- Possibilità di migrare un tenant su un DB separato senza refactoring - -*Contro:* -- Alembic migrations vanno applicate a tutti gli schema (N schema × migrazione) -- Connection pooling complesso: PgBouncer deve gestire schema switching -- Difficile fare query aggregate cross-tenant (es. monitoraggio globale SaaS) -- Overhead operativo significativo oltre i 100 tenant - -**Opzione B – Row-level con `tenant_id` + PostgreSQL RLS** - -*Pro:* -- Un solo schema, migrations applicate una volta -- Query cross-tenant per operazioni di sistema (monitoring, billing) -- Molto più semplice da gestire con Alembic e ORM -- Scala bene fino a decine di migliaia di tenant - -*Contro:* -- Bug nell'impostazione `current_tenant_id` può causare data leak → mitigato con RLS come secondo livello -- Restore di singolo tenant richiede WHERE clause su dump - -**Raccomandazione: Opzione B (row-level + RLS)** - -Per un SaaS B2B con caselle PEC, il numero di tenant nel medio termine è nell'ordine delle centinaia, non decine di migliaia. Il rischio principale (data leak cross-tenant) è mitigato da due strati indipendenti: -1. Applicativo: ogni query include `WHERE tenant_id = :current_tenant_id` -2. DB: PostgreSQL RLS come safety net, impostata tramite `SET LOCAL app.current_tenant_id = '...'` in ogni transazione - -Il vantaggio operativo (migrazioni semplici, query di monitoring, pool unico) supera i rischi, a condizione di avere test di integrazione che verificano il corretto isolamento. - ---- ### ADR-002 – Cifratura credenziali IMAP/SMTP a riposo diff --git a/GapAnalysis.md b/GapAnalysis.md new file mode 100644 index 0000000..7f53277 --- /dev/null +++ b/GapAnalysis.md @@ -0,0 +1,148 @@ +Report Gap Analysis – PEChub PEC Manager SaaS +Analisi condotta il 25/03/2026 sul codice sorgente (backend, worker, frontend). + +COSA E' IMPLEMENTATO E FUNZIONANTE +Infrastruttura e autenticazione + +Stack Docker completo (PostgreSQL, Redis, MinIO, Nginx, backend, worker, frontend) +Autenticazione JWT con refresh token silenzioso +2FA TOTP (setup + verifica) +Multi-tenancy row-level con RLS PostgreSQL +Cifratura credenziali IMAP/SMTP (AES-256-GCM in security.py) +Rate limiting su endpoint auth +WebSocket real-time per nuovi messaggi +CRUD completo: caselle, utenti, permessi, Virtual Box, notifiche, etichette +IMAP Sync Engine + +Pool asincrono con N coroutine IMAP, IDLE + polling fallback +Backoff esponenziale su disconnessione +Download EML grezzo su MinIO +Aggiornamento stato casella (error, sync_error_count) +Parser PEC + +Classificazione tipo messaggio da header X-Ricevuta/X-TipoRicevuta +Parsing MIME completo, estrazione allegati +EML-in-EML (ricevute annidate) +State machine outbound: sent → accepted → delivered / anomaly +Invio SMTP + +API POST /send con validazione e creazione send_job +Job send_pec con retry esponenziale (5 tentativi) +receipt_watcher: attesa ricevuta accettazione con alert anomalia a 24h +Upload raw EML inviato su MinIO +Frontend + +Inbox multi-casella con filtri, selezione multipla, azioni bulk +Posta inviata, Preferiti, Archiviati, Cestino +Dettaglio messaggio: corpo HTML/testo, allegati, ReceiptTree, download ZIP +Composizione PEC con RichTextEditor, To/Cc multipli, allegati +Gestione caselle, utenti, permessi, Virtual Box, notifiche, impostazioni +Pagina Multi-tenant (Super Admin) +Tag/etichette con colori su messaggi +Virtual Box con regole e assegnazioni utenti +COSA MANCA – PRIORITA' ALTA +1. Dispatch automatico notifiche (Sistema di notifiche incompleto al 60%) + +Il CRUD canali/regole/log e' implementato, ma manca tutto il lato dispatch +Non esiste worker/app/jobs/dispatch_notification.py +NotificationService non ha il metodo evaluate_rules(event_type, message) che valuta le regole e accoda i job +L'IMAP sync (sync.py) non chiama nulla al salvataggio di un nuovo messaggio +Il test canale webhook e email e' uno stub che restituisce sempre successo (solo Telegram ha invio reale) +La cifratura in notification_service.py usa base64 grezzo, non AES-256-GCM: i segreti (bot_token, webhook_secret, smtp_password) sono leggibili in chiaro nel DB +Canale WhatsApp: nessuna implementazione reale (stub completo) +Canale Email SMTP: nessuna implementazione reale (stub completo) +Risultato pratico: le notifiche sono configurabili ma non vengono mai inviate automaticamente +2. Ricerca avanzata full-text (Fase 5-B – completamente mancante) + +Non esiste backend/app/api/v1/search.py +Non esiste backend/app/services/search_service.py +Non esiste frontend/src/pages/Search/ ne' frontend/src/api/search.api.ts +La "ricerca" nell'InboxPage usa solo ILIKE su subject/from_address/body_text: e' lenta su volumi grandi e non cerca nel testo degli allegati +La colonna search_vector tsvector non e' nelle migrazioni Alembic attuali (0001–0007) +Non c'e' Apache Tika e non c'e' worker/app/jobs/index_message.py per l'estrazione testo da PDF/DOCX +3. Archiviazione Sostitutiva (Fase 6 – ~15% implementata) + +worker/app/archival/conservatore_client.py esiste (mock + produzione) ma non e' mai chiamato da nessun job reale +Mancano completamente: +worker/app/archival/sip_builder.py (generazione pacchetto SIP UNI SInCRO) +worker/app/archival/rdv_processor.py (parsing RdV XML) +worker/app/jobs/archive_batch.py (job selezione messaggi + upload SIP) +backend/app/api/v1/archival.py (endpoint GET /archival/batches, POST /archival/dip) +frontend/src/pages/Archival/ (pagina log versamenti, download RdV, richiesta DIP) +Il modello archival.py esiste ma la tabella archival_batches non e' nella migrazione corrente +La configurazione conservatore nelle impostazioni tenant e' pronta, ma il "pulsante" che avvia il versamento non esiste +4. Dashboard e Reportistica (Fase 7 – completamente mancante) + +Non esistono endpoint /reports/summary, /reports/export +Non esiste pagina Reports/Dashboard nel frontend (nessuna rotta in App.tsx) +Non c'e' generazione PDF (WeasyPrint) ne' export CSV +Non c'e' nessun grafico o KPI visibile (PEC ricevute/inviate oggi, anomalie, tasso consegna) +5. Audit Log – modello esistente, tutto il resto mancante + +Il modello audit_log.py e la tabella esistono +Non c'e' nessun endpoint API GET /audit-log per leggerlo +Non c'e' nessuna pagina frontend per la visualizzazione +Non e' chiaro se il backend registra effettivamente gli eventi (nessuna chiamata a AuditLog trovata nei servizi) +COSA MANCA – PRIORITA' MEDIA +6. Worker – job mancanti + +dispatch_notification.py – notifiche automatiche +archive_batch.py – versamenti verso conservatore +generate_report.py – export PDF/CSV +index_message.py – indicizzazione FTS allegati via Tika +7. Sicurezza – punti critici + +La cifratura dei segreti notifiche usa base64.b64encode() senza encryption reale: chiunque abbia accesso al DB puo' leggere bot_token Telegram, webhook secret, SMTP password in chiaro +Il CI/CD GitHub Actions e' disabilitato (ci.yml.bak): non c'e' lint automatico, test o build su PR +Non c'e' docker-compose.prod.yml (override produzione con configurazioni rafforzate) +Docs /docs, /redoc sono disabilitate in produzione ma non c'e' un meccanismo di secret scan +8. Invio PEC – funzionalita' mancanti + +Non c'e' Forward messaggio (la risposta e' parzialmente implementata in ComposePage ma non e' chiaro se funziona end-to-end) +Non c'e' endpoint per forzare un re-sync manuale di una casella (utile dopo un errore di connessione) +Non c'e' indicazione visiva del numero di messaggi non letti nella sidebar per casella +La barra ricerca nell'Inbox non ha filtri per data (da/a), stato PEC, tipo PEC +9. Ruolo Supervisor + +Il ruolo supervisor e' definito nell'enum DB e nella documentazione ma non ha logica differenziata dal operator nel codice: is_admin controlla solo admin/super_admin, tutto il resto e' trattato uguale +10. Gestione quote casella + +L'evento mailbox.quota_warning e' definito negli enum delle notifiche ma non e' mai generato dal worker (nessuna stima della quota IMAP) +COSA MANCA – PRIORITA' BASSA (Hardening / Go-Live) +11. Monitoring e osservabilita' + +Non c'e' infra/prometheus/ ne' infra/grafana/ (previsti in ARCHITECTURE.md ma non creati) +Non c'e' log aggregation (Loki/ELK) +Non ci sono metriche esposte dal backend (es. /metrics endpoint Prometheus) +12. Backup automatico + +Non c'e' script o cronjob per pg_dump automatico verso MinIO +13. Test coverage + +I test di integrazione esistenti coprono auth, users, send API +Non ci sono test per: messages API, permissions API, virtual_boxes, notifications, archival +Non c'e' copertura frontend (nessun test Vitest/Playwright presente) +14. GDPR + +Non c'e' endpoint DELETE /tenants/{id} per cancellazione completa dati tenant con audit trail +RIEPILOGO STATO PER FASE +Fase Descrizione Stato +1 Fondamenta + Auth + Multi-tenancy Completa +1-A Permessi granulari per casella Completa +2 IMAP Sync Engine Completa +3 Parser PEC e Tracking Ricevute Completa +4 Invio SMTP con retry Completa +5 Frontend base (inbox, compose, admin) Completa +5-A Virtual Box Completa +5-B Ricerca avanzata full-text Non iniziata +5-C Notifiche multi-canale Struttura pronta, dispatch mancante +6 Archiviazione sostitutiva ~15% (client mock presente, tutto il resto mancante) +7 Dashboard e Reportistica Non iniziata +8 Hardening, test, go-live Parziale (sicurezza base presente, monitoring/backup/CI mancanti) +PRIORITA' DI INTERVENTO CONSIGLIATA +Notifiche dispatch – e' la funzionalita' piu' vicina al completamento: la struttura dati e' pronta, manca solo il wiring tra IMAP sync, il servizio di valutazione regole e il job worker. Ha alto impatto operativo. +Ricerca avanzata – blocca l'usabilita' su volumi di posta significativi. La ricerca ILIKE attuale scala male. +Archiviazione sostitutiva – obbligatoria per la compliance normativa dei clienti PA/professionisti. +Fix sicurezza cifratura notifiche – critico: i segreti (bot token, webhook secret) sono attualmente non cifrati nel DB. +Audit log – necessario per compliance e per l'utilizzo enterprise. +Dashboard/Report – utile commercialmente ma non bloccante per l'operativita'. \ No newline at end of file diff --git a/backend/alembic/versions/0008_full_text_search.py b/backend/alembic/versions/0008_full_text_search.py new file mode 100644 index 0000000..a173f77 --- /dev/null +++ b/backend/alembic/versions/0008_full_text_search.py @@ -0,0 +1,76 @@ +"""add full text search vector to messages and extracted_text to attachments + +Revision ID: 0008 +Revises: 0007 +Create Date: 2026-03-25 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = '0008' +down_revision = '0007' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Aggiunge colonna search_vector a messages + op.add_column( + 'messages', + sa.Column('search_vector', postgresql.TSVECTOR(), nullable=True), + ) + + # 2. Aggiunge colonna extracted_text ad attachments (testo estratto da PDF/DOCX) + op.add_column( + 'attachments', + sa.Column('extracted_text', sa.Text(), nullable=True), + ) + + # 3. Indice GIN per ricerca full-text veloce + op.execute( + "CREATE INDEX idx_messages_fts ON messages USING gin(search_vector) " + "WHERE search_vector IS NOT NULL" + ) + + # 4. Funzione trigger che aggiorna search_vector quando cambiano i campi testuali + op.execute(""" + CREATE OR REPLACE FUNCTION messages_search_vector_update() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := + setweight(to_tsvector('italian', coalesce(NEW.subject, '')), 'A') || + setweight(to_tsvector('simple', coalesce(NEW.from_address, '')), 'B') || + setweight(to_tsvector('simple', + coalesce(array_to_string(NEW.to_addresses, ' '), '')), 'B') || + setweight(to_tsvector('italian', coalesce(NEW.body_text, '')), 'C'); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """) + + # 5. Crea trigger (si attiva su INSERT e UPDATE dei campi rilevanti) + op.execute(""" + CREATE TRIGGER trg_messages_search_vector + BEFORE INSERT OR UPDATE OF subject, from_address, to_addresses, body_text + ON messages + FOR EACH ROW EXECUTE FUNCTION messages_search_vector_update(); + """) + + # 6. Backfill: popola search_vector per i messaggi esistenti + op.execute(""" + UPDATE messages SET search_vector = + setweight(to_tsvector('italian', coalesce(subject, '')), 'A') || + setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') || + setweight(to_tsvector('simple', + coalesce(array_to_string(to_addresses, ' '), '')), 'B') || + setweight(to_tsvector('italian', coalesce(body_text, '')), 'C') + WHERE search_vector IS NULL + """) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS trg_messages_search_vector ON messages") + op.execute("DROP FUNCTION IF EXISTS messages_search_vector_update()") + op.execute("DROP INDEX IF EXISTS idx_messages_fts") + op.drop_column('attachments', 'extracted_text') + op.drop_column('messages', 'search_vector') diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 137b3e2..f003ec7 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -26,6 +26,8 @@ from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.services.search_service import SearchService + from app.config import get_settings from app.core.exceptions import ForbiddenError, NotFoundError from app.database import get_db @@ -184,8 +186,11 @@ async def list_messages( is_starred: Optional[bool] = Query(None), is_archived: Optional[bool] = Query(False), is_trashed: Optional[bool] = Query(False), - search: Optional[str] = Query(None, max_length=200), + search: Optional[str] = Query(None, max_length=500), pec_type: Optional[str] = Query(None), + # Filtri data (ISO 8601, es. 2026-01-01T00:00:00Z) + date_from: Optional[datetime] = Query(None, description="Data minima (received_at o sent_at)"), + date_to: Optional[datetime] = Query(None, description="Data massima (received_at o sent_at)"), # Paginazione page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), @@ -195,7 +200,8 @@ async def list_messages( - `is_archived=False` (default) esclude i messaggi archiviati. - `is_trashed=False` (default) esclude i messaggi nel cestino. - - `search` cerca su subject, from_address, to_addresses. + - `search` usa ricerca full-text (tsvector) con fallback ILIKE. + - `date_from` / `date_to` filtrano per data ricezione o invio. - `vbox_id` filtra per Virtual Box assegnata all'utente corrente. """ # Determinare le caselle visibili (normale check permessi) @@ -284,16 +290,30 @@ async def list_messages( if is_trashed is not None: q = q.where(Message.is_trashed == is_trashed) + # ── Full-text search (FTS con fallback ILIKE per messaggi non indicizzati) ─── if search: - term = f"%{search}%" + from sqlalchemy import case as sa_case + + tsquery = func.websearch_to_tsquery("italian", search) + term_like = f"%{search}%" q = q.where( or_( - Message.subject.ilike(term), - Message.from_address.ilike(term), - Message.body_text.ilike(term), + Message.search_vector.op("@@")(tsquery), + # Fallback per messaggi non ancora indicizzati dal worker + Message.search_vector.is_(None) & or_( + Message.subject.ilike(term_like), + Message.from_address.ilike(term_like), + Message.body_text.ilike(term_like), + ), ) ) + # ── Filtri data ─────────────────────────────────────────────────────────── + if date_from: + q = q.where(or_(Message.received_at >= date_from, Message.sent_at >= date_from)) + if date_to: + q = q.where(or_(Message.received_at <= date_to, Message.sent_at <= date_to)) + # Applica le regole della Virtual Box (AND tra le regole) for rule in vbox_rules: q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value) @@ -302,13 +322,23 @@ async def list_messages( count_q = select(func.count()).select_from(q.subquery()) total = (await db.execute(count_q)).scalar_one() - # Ordinamento e paginazione + # Ordinamento: se c'e' una ricerca, ordina per rilevanza FTS, poi data + if search: + from sqlalchemy import case as sa_case + + tsquery_ord = func.websearch_to_tsquery("italian", search) + rank_expr = sa_case( + (Message.search_vector.isnot(None), func.ts_rank(Message.search_vector, tsquery_ord)), + else_=0.0, + ) + order_clauses = [rank_expr.desc(), Message.received_at.desc().nullslast(), Message.created_at.desc()] + else: + order_clauses = [Message.received_at.desc().nullslast(), Message.created_at.desc()] + + # Paginazione q = ( q.options(selectinload(Message.labels)) - .order_by( - Message.received_at.desc().nullslast(), - Message.created_at.desc(), - ) + .order_by(*order_clauses) .offset((page - 1) * page_size) .limit(page_size) ) diff --git a/backend/app/models/message.py b/backend/app/models/message.py index b037b01..63506d0 100644 --- a/backend/app/models/message.py +++ b/backend/app/models/message.py @@ -4,6 +4,7 @@ Modelli Message, Attachment, SendJob. import uuid from datetime import datetime +from typing import Any from sqlalchemy import ( ARRAY, @@ -18,7 +19,7 @@ from sqlalchemy import ( Text, func, ) -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import TSVECTOR, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -96,6 +97,9 @@ class Message(Base): raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True) + # Full-text search vector (aggiornato da trigger DB + worker per allegati) + search_vector: Mapped[Any | None] = mapped_column(TSVECTOR(), nullable=True) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) @@ -126,6 +130,7 @@ class Message(Base): postgresql_where="parent_message_id IS NOT NULL", ), Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"), + Index("idx_messages_fts", "search_vector", postgresql_using="gin"), ) def __repr__(self) -> str: @@ -149,6 +154,8 @@ class Attachment(Base): size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True) storage_path: Mapped[str] = mapped_column(Text, nullable=False) checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + # Testo estratto dal worker (solo PDF e DOCX) per la ricerca full-text + extracted_text: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py new file mode 100644 index 0000000..9b9baf6 --- /dev/null +++ b/backend/app/services/search_service.py @@ -0,0 +1,139 @@ +""" +Servizio di ricerca full-text per i messaggi PEC. + +Utilizza i vettori tsvector di PostgreSQL per ricerche veloci su: + - oggetto (peso A) + - mittente / destinatari (peso B) + - corpo del messaggio (peso C) + - testo estratto dagli allegati PDF/DOCX (peso D) + +Se search_vector e' NULL (messaggio non ancora indicizzato dal worker), +cade back automaticamente a ILIKE sulle colonne base. +""" + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import case, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.message import Message + + +class SearchService: + """Incapsula la logica di ricerca full-text sui messaggi.""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def search_messages( + self, + tenant_id: uuid.UUID, + search_term: str, + visible_mailbox_ids: Optional[list[uuid.UUID]], + mailbox_id: Optional[uuid.UUID] = None, + direction: Optional[str] = None, + state: Optional[str] = None, + pec_type: Optional[str] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + is_archived: Optional[bool] = False, + is_trashed: Optional[bool] = False, + is_starred: Optional[bool] = None, + is_read: Optional[bool] = None, + page: int = 1, + page_size: int = 50, + ) -> tuple[list[Message], int]: + """ + Ricerca full-text nei messaggi. + + Logica: + 1. Messaggi con search_vector non NULL → usa @@ operator + ts_rank + 2. Messaggi con search_vector NULL → fallback ILIKE (non ancora indicizzati) + 3. Applica tutti i filtri aggiuntivi (data, stato, tipo, direzione, ecc.) + 4. Ordina per rilevanza FTS desc, poi per data desc + """ + q = select(Message).where( + Message.tenant_id == tenant_id, + Message.parent_message_id.is_(None), + ) + + # Restrizione caselle visibili (permessi) + if visible_mailbox_ids is not None: + if not visible_mailbox_ids: + return [], 0 + q = q.where(Message.mailbox_id.in_(visible_mailbox_ids)) + + # Filtri opzionali + if mailbox_id: + q = q.where(Message.mailbox_id == mailbox_id) + if direction: + q = q.where(Message.direction == direction) + if state: + q = q.where(Message.state == state) + if pec_type: + q = q.where(Message.pec_type == pec_type) + if is_archived is not None: + q = q.where(Message.is_archived == is_archived) + if is_trashed is not None: + q = q.where(Message.is_trashed == is_trashed) + if is_starred is not None: + q = q.where(Message.is_starred == is_starred) + if is_read is not None: + q = q.where(Message.is_read == is_read) + + # Filtri data: cerca sia su received_at che su sent_at + if date_from: + q = q.where( + or_( + Message.received_at >= date_from, + Message.sent_at >= date_from, + ) + ) + if date_to: + q = q.where( + or_( + Message.received_at <= date_to, + Message.sent_at <= date_to, + ) + ) + + # Full-text search con fallback ILIKE + tsquery = func.websearch_to_tsquery("italian", search_term) + term_like = f"%{search_term}%" + + fts_condition = Message.search_vector.op("@@")(tsquery) + ilike_fallback = Message.search_vector.is_(None) & or_( + Message.subject.ilike(term_like), + Message.from_address.ilike(term_like), + Message.body_text.ilike(term_like), + ) + + q = q.where(or_(fts_condition, ilike_fallback)) + + # Conteggio totale (senza paginazione) + count_q = select(func.count()).select_from(q.subquery()) + total: int = (await self.db.execute(count_q)).scalar_one() + + # Ordinamento per rilevanza FTS, poi data + rank_expr = case( + (Message.search_vector.isnot(None), func.ts_rank(Message.search_vector, tsquery)), + else_=0.0, + ) + + q = ( + q.options(selectinload(Message.labels)) + .order_by( + rank_expr.desc(), + Message.received_at.desc().nullslast(), + Message.created_at.desc(), + ) + .offset((page - 1) * page_size) + .limit(page_size) + ) + + result = await self.db.execute(q) + items = list(result.scalars().all()) + return items, total diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eda1ddb..b0599fc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { SettingsPage } from '@/pages/Settings/SettingsPage' import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage' import { NotificationsPage } from '@/pages/Notifications/NotificationsPage' import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage' +import { SearchPage } from '@/pages/Search/SearchPage' /** * Routing principale dell'applicazione PEChub. @@ -76,6 +77,9 @@ export default function App() { {/* Super Admin – Gestione Multi-Tenant */} } /> + {/* Ricerca avanzata full-text */} + } /> + {/* Profilo utente */} } /> diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts index a034d79..b7d08be 100644 --- a/frontend/src/api/messages.api.ts +++ b/frontend/src/api/messages.api.ts @@ -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 { diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 827f72f..6a12575 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -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() { )} + {/* ── Ricerca avanzata ── */} +
+
+
+ + cn( + 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', + isActive + ? 'bg-blue-600 text-white' + : 'text-gray-300 hover:bg-gray-700 hover:text-white', + collapsed && 'justify-center px-2', + ) + } + title={collapsed ? 'Ricerca avanzata' : undefined} + > + + {!collapsed && Ricerca} + +
+
+ {/* ── Nuova PEC ── */}
diff --git a/frontend/src/pages/Inbox/InboxPage.tsx b/frontend/src/pages/Inbox/InboxPage.tsx index e28bc30..4dfcb36 100644 --- a/frontend/src/pages/Inbox/InboxPage.tsx +++ b/frontend/src/pages/Inbox/InboxPage.tsx @@ -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>(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 )} + + {/* Pulsante filtri avanzati */} +
+ + {/* ── Pannello filtri avanzati ── */} + {showAdvancedFilters && ( +
+ {/* Data da */} +
+ + setDateFrom(e.target.value)} + /> +
+ + {/* Data a */} +
+ + setDateTo(e.target.value)} + /> +
+ + {/* Tipo PEC */} +
+ + +
+ + {/* Reset filtri */} + {activeAdvancedFiltersCount > 0 && ( +
+ +
+ )} +
+ )}
{/* ── Barra azioni bulk ── */} diff --git a/frontend/src/pages/Search/SearchPage.tsx b/frontend/src/pages/Search/SearchPage.tsx new file mode 100644 index 0000000..d6d639c --- /dev/null +++ b/frontend/src/pages/Search/SearchPage.tsx @@ -0,0 +1,556 @@ +/** + * SearchPage – ricerca full-text avanzata tra tutti i messaggi PEC. + * + * Funzionalita': + * - Barra di ricerca full-text (usa FTS su search_vector PostgreSQL) + * - Pannello filtri avanzati collassabile: + * - Data da / Data a + * - Direzione (in arrivo / inviata) + * - Stato PEC + * - Tipo PEC + * - Casella specifica + * - Lista risultati ordinata per rilevanza + * - Paginazione + */ +import { useState, useEffect, useCallback } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { + Search, + SlidersHorizontal, + ChevronDown, + ChevronUp, + X, + Inbox, + Send, + MailOpen, + Mail, + ChevronLeft, + ChevronRight, +} from 'lucide-react' +import { useQuery } from '@tanstack/react-query' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { PecStateBadge } from '@/components/PecBadge/PecBadge' +import { TagBadgeList } from '@/components/TagManager/TagBadge' +import { messagesApi } from '@/api/messages.api' +import { mailboxesApi } from '@/api/mailboxes.api' +import { formatRelative, truncate } from '@/lib/utils' +import { cn } from '@/lib/utils' +import type { MessageResponse } from '@/types/api.types' + +// ─── Costanti ───────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 50 + +const PEC_STATES = [ + { value: '', label: 'Tutti gli stati' }, + { value: 'received', label: 'Ricevuta' }, + { value: 'sent', label: 'Inviata' }, + { value: 'accepted', label: 'Accettata' }, + { value: 'delivered', label: 'Consegnata' }, + { value: 'anomaly', label: 'Anomalia' }, + { value: 'failed', label: 'Fallita' }, +] + +const PEC_TYPES = [ + { value: '', label: 'Tutti i tipi' }, + { value: 'posta_certificata', label: 'Posta certificata' }, + { value: 'accettazione', label: 'Accettazione' }, + { value: 'avvenuta_consegna', label: 'Avvenuta consegna' }, + { value: 'mancata_consegna', label: 'Mancata consegna' }, + { value: 'non_accettazione', label: 'Non accettazione' }, + { value: 'presa_in_carico', label: 'Presa in carico' }, + { value: 'errore_consegna', label: 'Errore consegna' }, + { value: 'preavviso_mancata_consegna', label: 'Preavviso mancata consegna' }, + { value: 'rilevazione_virus', label: 'Rilevazione virus' }, +] + +const DIRECTIONS = [ + { value: '', label: 'Tutte le direzioni' }, + { value: 'inbound', label: 'In arrivo' }, + { value: 'outbound', label: 'Inviata' }, +] + +// ─── Componente principale ──────────────────────────────────────────────────── + +export function SearchPage() { + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + + // Legge i parametri dall'URL (permette di condividere/bookmark la ricerca) + const [searchInput, setSearchInput] = useState(searchParams.get('q') || '') + const [committedSearch, setCommittedSearch] = useState(searchParams.get('q') || '') + const [showFilters, setShowFilters] = useState(false) + const [page, setPage] = useState(1) + + // Filtri avanzati + const [dateFrom, setDateFrom] = useState(searchParams.get('date_from') || '') + const [dateTo, setDateTo] = useState(searchParams.get('date_to') || '') + const [direction, setDirection] = useState(searchParams.get('direction') || '') + const [state, setState] = useState(searchParams.get('state') || '') + const [pecType, setPecType] = useState(searchParams.get('pec_type') || '') + const [mailboxId, setMailboxId] = useState(searchParams.get('mailbox_id') || '') + + // Caselle disponibili per il filtro + const { data: mailboxesData } = useQuery({ + queryKey: ['mailboxes'], + queryFn: () => mailboxesApi.list(), + staleTime: 5 * 60 * 1000, + }) + const mailboxes = mailboxesData?.items ?? [] + + // Numero di filtri attivi + const activeFiltersCount = [dateFrom, dateTo, direction, state, pecType, mailboxId].filter(Boolean).length + + // Sincronizza l'URL quando cambiano i filtri + useEffect(() => { + const params: Record = {} + if (committedSearch) params.q = committedSearch + if (dateFrom) params.date_from = dateFrom + if (dateTo) params.date_to = dateTo + if (direction) params.direction = direction + if (state) params.state = state + if (pecType) params.pec_type = pecType + if (mailboxId) params.mailbox_id = mailboxId + setSearchParams(params, { replace: true }) + }, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId, setSearchParams]) + + // Reset pagina quando cambiano i filtri + useEffect(() => { + setPage(1) + }, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId]) + + // Costruisce i filtri per l'API + const filters = { + search: committedSearch || undefined, + date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined, + date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined, + direction: direction as 'inbound' | 'outbound' | undefined || undefined, + state: state || undefined, + pec_type: pecType || undefined, + mailbox_id: mailboxId || undefined, + is_archived: undefined as boolean | undefined, + is_trashed: false, + page, + page_size: PAGE_SIZE, + } + + // Query messaggi + const { + data: messagesData, + isLoading, + isFetching, + } = useQuery({ + queryKey: ['search', filters], + queryFn: () => messagesApi.list(filters), + enabled: !!(committedSearch || dateFrom || dateTo || direction || state || pecType || mailboxId), + }) + + const messages = messagesData?.items || [] + const total = messagesData?.total || 0 + const totalPages = Math.ceil(total / PAGE_SIZE) + const hasQuery = !!(committedSearch || activeFiltersCount > 0) + + const handleSearch = useCallback(() => { + setCommittedSearch(searchInput.trim()) + setPage(1) + }, [searchInput]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSearch() + } + + const handleClearAll = () => { + setSearchInput('') + setCommittedSearch('') + setDateFrom('') + setDateTo('') + setDirection('') + setState('') + setPecType('') + setMailboxId('') + setPage(1) + } + + const handleMessageClick = (message: MessageResponse) => { + navigate(`/messages/${message.id}`) + } + + return ( +
+ {/* ── Header ricerca ── */} +
+
+

+ + Ricerca avanzata +

+

+ Ricerca full-text su oggetto, corpo del messaggio e allegati PDF/DOCX +

+
+ + {/* Barra di ricerca principale */} +
+
+ + setSearchInput(e.target.value)} + onKeyDown={handleKeyDown} + /> + {searchInput && ( + + )} +
+ + +
+ + {/* Pannello filtri avanzati */} + {showFilters && ( +
+
+ {/* Data da */} +
+ + setDateFrom(e.target.value)} + /> +
+ + {/* Data a */} +
+ + setDateTo(e.target.value)} + /> +
+ + {/* Direzione */} +
+ + +
+ + {/* Stato PEC */} +
+ + +
+ + {/* Tipo PEC */} +
+ + +
+ + {/* Casella */} + {mailboxes.length > 0 && ( +
+ + +
+ )} +
+ + {/* Pulsante reset filtri */} + {activeFiltersCount > 0 && ( +
+ +
+ )} +
+ )} +
+ + {/* ── Risultati ── */} +
+ {!hasQuery ? ( +
+ +

Inizia a cercare

+

+ Digita un termine nella barra di ricerca o usa i filtri avanzati per trovare i messaggi +

+
+ ) : isLoading || isFetching ? ( +
+
+
+

Ricerca in corso…

+
+
+ ) : messages.length === 0 ? ( +
+ +

Nessun risultato

+

+ Prova a modificare i termini di ricerca o i filtri +

+ {hasQuery && ( + + )} +
+ ) : ( + <> + {/* Intestazione risultati */} +
+

+ {total}{' '} + {total === 1 ? 'risultato trovato' : 'risultati trovati'} + {committedSearch && ( + <> per "{committedSearch}" + )} +

+ {(isFetching) && ( +
+ )} +
+ + {/* Lista risultati */} +
+ {messages.map((message) => ( + m.id === message.mailbox_id)?.email_address + } + onClick={() => handleMessageClick(message)} + /> + ))} +
+ + )} +
+ + {/* ── Paginazione ── */} + {totalPages > 1 && ( +
+

+ Pagina {page} di {totalPages} ({total} risultati) +

+
+ + +
+
+ )} +
+ ) +} + +// ─── Riga singolo risultato ─────────────────────────────────────────────────── + +interface SearchResultRowProps { + message: MessageResponse + searchTerm: string + mailboxName?: string + onClick: () => void +} + +function SearchResultRow({ message, searchTerm, mailboxName, onClick }: SearchResultRowProps) { + const isUnread = !message.is_read && message.direction === 'inbound' + + // Evidenzia il termine cercato nel testo + const highlight = (text: string | null | undefined, term: string): React.ReactNode => { + if (!text || !term) return text || '' + const idx = text.toLowerCase().indexOf(term.toLowerCase()) + if (idx === -1) return truncate(text, 150) + const start = Math.max(0, idx - 60) + const end = Math.min(text.length, idx + term.length + 90) + const snippet = (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '') + const snipIdx = snippet.toLowerCase().indexOf(term.toLowerCase()) + if (snipIdx === -1) return snippet + return ( + <> + {snippet.slice(0, snipIdx)} + + {snippet.slice(snipIdx, snipIdx + term.length)} + + {snippet.slice(snipIdx + term.length)} + + ) + } + + return ( +
+ {/* Icona direzione */} +
+ {message.direction === 'inbound' ? ( + isUnread ? ( + + ) : ( + + ) + ) : ( + + )} +
+ + {/* Contenuto */} +
+ {/* Riga 1: mittente + badge casella + stato + data */} +
+
+ + {message.direction === 'inbound' + ? (message.from_address || 'Mittente sconosciuto') + : (message.to_addresses?.[0] || 'Destinatario sconosciuto')} + + {mailboxName && ( + + {mailboxName} + + )} +
+
+ + + {formatRelative(message.received_at || message.sent_at || message.created_at)} + +
+
+ + {/* Riga 2: oggetto */} +
+ {isUnread && } +

+ {searchTerm + ? highlight(message.subject || '(nessun oggetto)', searchTerm) + : truncate(message.subject || '(nessun oggetto)', 100)} +

+
+ + {/* Riga 3: snippet corpo */} + {message.body_text && ( +

+ {searchTerm ? highlight(message.body_text, searchTerm) : truncate(message.body_text, 150)} +

+ )} + + {/* Tag */} + {message.labels && message.labels.length > 0 && ( +
+ +
+ )} +
+ + {/* Indicatore allegati */} + {message.has_attachments && ( + + )} +
+ ) +} diff --git a/worker/app/imap/sync.py b/worker/app/imap/sync.py index ca21e12..2346f63 100644 --- a/worker/app/imap/sync.py +++ b/worker/app/imap/sync.py @@ -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 diff --git a/worker/app/jobs/index_message.py b/worker/app/jobs/index_message.py new file mode 100644 index 0000000..9a7ed3c --- /dev/null +++ b/worker/app/jobs/index_message.py @@ -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" + ) diff --git a/worker/pyproject.toml b/worker/pyproject.toml index c31231c..8681ae5 100644 --- a/worker/pyproject.toml +++ b/worker/pyproject.toml @@ -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]