# PecFlow – Architettura di Sistema > **Documento redatto il:** 2026-03-18 | **Ultima revisione:** 2026-03-18 > **Versione:** 2.0 > **Stato:** Draft – da approvare prima di iniziare la Fase 1 --- ## Indice 1. [Struttura Repository](#1-struttura-repository) 2. [Modello Dati](#2-modello-dati) 3. [Piano di Sviluppo a Fasi](#3-piano-di-sviluppo-a-fasi) 4. [Decisioni Architetturali](#4-decisioni-architetturali) 5. [Sistemi Avanzati](#5-sistemi-avanzati) - 5.1 [Permessi Utente Granulari](#51-permessi-utente-granulari) - 5.2 [Virtual Box](#52-virtual-box) - 5.3 [Ricerca Avanzata](#53-ricerca-avanzata) - 5.4 [Sistema di Notifiche Multi-canale](#54-sistema-di-notifiche-multi-canale) --- ## 1. Struttura Repository Monorepo con workspace separati. Il confine di responsabilità è netto: ogni cartella di primo livello è un deployable (o una libreria condivisa) indipendente. ``` PecFlow/ ← root del monorepo │ ├── .github/ ← CI/CD GitHub Actions │ ├── workflows/ │ │ ├── ci.yml # lint + test su ogni PR │ │ ├── cd-staging.yml # deploy automatico su staging │ │ └── cd-production.yml # deploy manuale su prod (approval gate) │ └── CODEOWNERS # chi deve approvare le PR per area │ ├── .husky/ ← pre-commit hooks (lint, secret scan) ├── .env.example ← template variabili d'ambiente (NO valori reali) ├── .gitignore ├── .gitattributes ├── docker-compose.yml ← stack locale completo (dev) ├── docker-compose.prod.yml ← override per produzione ├── Makefile ← comandi rapidi: make dev, make test, make migrate ├── ARCHITECTURE.md ← questo file ├── CONTRIBUTING.md │ ├── backend/ ═══════════════════════════════ │ │ ← FastAPI – API HTTP + WebSocket │ ├── Dockerfile │ ├── pyproject.toml # dipendenze (uv / poetry) │ ├── alembic.ini # configurazione Alembic migrations │ │ │ ├── alembic/ ← migrazioni DB versionate │ │ ├── env.py │ │ └── versions/ │ │ └── 0001_initial_schema.py │ │ │ ├── app/ │ │ ├── main.py # entrypoint FastAPI, registra router │ │ ├── config.py # Settings (pydantic-settings, legge .env) │ │ ├── database.py # engine SQLAlchemy, session factory │ │ ├── dependencies.py # get_db, get_current_user, ecc. │ │ │ │ │ ├── api/ ← router HTTP (versioned) │ │ │ └── v1/ │ │ │ ├── auth.py # login, refresh, logout, 2FA │ │ │ ├── tenants.py # CRUD tenant (super-admin) │ │ │ ├── users.py # CRUD utenti │ │ │ ├── mailboxes.py # CRUD caselle PEC │ │ │ ├── messages.py # lista, dettaglio, search PEC │ │ │ ├── search.py # ricerca avanzata full-text + filtri │ │ │ ├── send.py # invio PEC │ │ │ ├── archival.py # versamenti, RdV, DIP │ │ │ ├── reports.py # export PDF/CSV │ │ │ ├── permissions.py # permessi granulari per casella │ │ │ ├── virtual_boxes.py # CRUD virtual box + assegnazioni │ │ │ ├── notifications.py # CRUD canali e regole notifiche │ │ │ └── ws.py # WebSocket endpoint (Socket.io) │ │ │ │ │ ├── models/ ← SQLAlchemy ORM models (1:1 con tabelle) │ │ │ ├── tenant.py │ │ │ ├── user.py │ │ │ ├── mailbox.py │ │ │ ├── message.py │ │ │ ├── receipt.py │ │ │ ├── archival.py │ │ │ ├── permission.py # mailbox_permissions │ │ │ ├── virtual_box.py # virtual_boxes, rules, assignments │ │ │ ├── notification.py # notification_channels, rules, log │ │ │ └── audit_log.py │ │ │ │ │ ├── schemas/ ← Pydantic schemas (request/response DTOs) │ │ │ ├── auth.py │ │ │ ├── mailbox.py │ │ │ ├── message.py │ │ │ ├── search.py # SearchQuery, SearchResult │ │ │ ├── permission.py │ │ │ ├── virtual_box.py │ │ │ ├── notification.py │ │ │ └── archival.py │ │ │ │ │ ├── services/ ← logica di business (nessun DB diretto) │ │ │ ├── auth_service.py # JWT, TOTP, password hash │ │ │ ├── mailbox_service.py # cifratura credenziali │ │ │ ├── message_service.py # ricerca, etichette, stati │ │ │ ├── search_service.py # query builder FTS + filtri compositi │ │ │ ├── permission_service.py # verifica accesso utente/casella │ │ │ ├── virtual_box_service.py # applica filtri VBox a query messaggi │ │ │ ├── notification_service.py # valuta regole + dispatcha notifiche │ │ │ ├── send_service.py # invio SMTP, salvataggio ricevute │ │ │ └── archival_service.py # generazione SIP, chiamate conservatore │ │ │ │ │ ├── core/ ← utilities trasversali │ │ │ ├── security.py # cifratura AES-256-GCM, hashing │ │ │ ├── exceptions.py # eccezioni applicative custom │ │ │ ├── pagination.py │ │ │ └── logging.py # structured logging (JSON) │ │ │ │ │ └── websocket/ │ │ └── manager.py # gestione connessioni WS per tenant │ │ │ └── tests/ │ ├── unit/ │ │ ├── test_auth_service.py │ │ └── test_security.py │ ├── integration/ │ │ ├── test_api_auth.py │ │ ├── test_api_messages.py │ │ └── conftest.py # fixture DB in-memory (SQLite / testcontainers) │ └── e2e/ # (opzionale, gestiti da Playwright lato frontend) │ ├── worker/ ═══════════════════════════════ │ │ ← BullMQ worker (Node.js / Python Celery alt.) │ │ Scelta: Python (arq) per non aggiungere runtime │ ├── Dockerfile │ ├── pyproject.toml │ │ │ └── app/ │ ├── main.py # entrypoint arq / asyncio loop │ ├── config.py │ │ │ ├── imap/ ← IMAP sync engine │ │ ├── pool.py # MailboxPool: gestisce N connessioni IMAP │ │ ├── connection.py # IMAPConnection: IDLE + polling fallback │ │ ├── sync.py # sincronizzazione UID, fetch envelope + body │ │ └── reconnect.py # strategia backoff esponenziale │ │ │ ├── parsers/ ← parser PEC (lib interna, riusata anche da backend) │ │ ├── pec_parser.py # estrae tipo messaggio PEC, ricevute EML-in-EML │ │ ├── eml_parser.py # parsing MIME generico │ │ └── receipt_extractor.py # estrae campi da ricevute (tipo, timestamp, msgid) │ │ │ ├── smtp/ ← invio SMTP │ │ ├── sender.py # connessione STARTTLS/SSL, invio, retry │ │ └── receipt_watcher.py # attende ricevuta accettazione/consegna post-invio │ │ │ ├── archival/ ← archiviazione sostitutiva │ │ ├── sip_builder.py # genera pacchetto SIP (UNI SInCRO) │ │ ├── conservatore_client.py # client HTTP verso API conservatore AgID │ │ └── rdv_processor.py # processa ricevuta di versamento (RdV) │ │ │ ├── notifications/ ← dispatcher notifiche async │ │ ├── dispatcher.py # valuta regole e sceglie canali │ │ ├── channels/ │ │ │ ├── webhook.py # HTTP POST con payload JSON + HMAC signature │ │ │ ├── email_smtp.py # invio email via SMTP (aiosmtplib) │ │ │ ├── telegram.py # Telegram Bot API (sendMessage) │ │ │ └── whatsapp.py # Meta Cloud API / Twilio WhatsApp │ │ └── retry.py # retry con backoff per canali falliti │ │ │ ├── search/ ← indicizzazione full-text │ │ ├── indexer.py # aggiorna search_vector su nuovo messaggio │ │ └── text_extractor.py # estrae testo da PDF/DOCX allegati (Tika HTTP) │ │ │ ├── jobs/ ← definizione job BullMQ / arq │ │ ├── sync_mailbox.py # job: sincronizza singola casella │ │ ├── send_pec.py # job: invia PEC con retry │ │ ├── archive_batch.py # job: versamento batch verso conservatore │ │ ├── generate_report.py # job: genera PDF/CSV report │ │ ├── dispatch_notification.py # job: invia notifiche per un evento PEC │ │ └── index_message.py # job: estrae testo allegati e aggiorna FTS │ │ │ └── tests/ │ ├── unit/ │ │ ├── test_pec_parser.py │ │ ├── test_sip_builder.py │ │ ├── test_notification_rules.py │ │ └── test_virtual_box_filter.py │ └── integration/ │ ├── test_imap_sync.py # usa GreenMail / Mailhog mock IMAP │ └── test_notification_dispatch.py │ ├── frontend/ ═══════════════════════════════ │ │ ← React 18 + TypeScript + Vite │ ├── Dockerfile │ ├── package.json │ ├── tsconfig.json │ ├── vite.config.ts │ │ │ ├── src/ │ │ ├── main.tsx │ │ ├── App.tsx │ │ │ │ │ ├── api/ ← client HTTP (axios instances per risorsa) │ │ │ ├── client.ts # axios base + interceptor JWT refresh │ │ │ ├── auth.api.ts │ │ │ ├── messages.api.ts │ │ │ ├── search.api.ts # ricerca avanzata │ │ │ ├── mailboxes.api.ts │ │ │ ├── permissions.api.ts │ │ │ ├── virtual_boxes.api.ts │ │ │ ├── notifications.api.ts │ │ │ └── archival.api.ts │ │ │ │ │ ├── store/ ← Zustand stores │ │ │ ├── auth.store.ts │ │ │ ├── mailbox.store.ts │ │ │ ├── inbox.store.ts │ │ │ └── search.store.ts # stato ricerca (query, filtri, risultati) │ │ │ │ │ ├── pages/ │ │ │ ├── Login/ │ │ │ ├── Inbox/ # lista messaggi PEC (filtra per VBox se assegnato) │ │ │ ├── Search/ # ricerca avanzata con pannello filtri │ │ │ ├── MessageDetail/ # dettaglio + ricevute + allegati │ │ │ ├── Compose/ # composizione invio PEC │ │ │ ├── Mailboxes/ # gestione caselle (admin) │ │ │ ├── Permissions/ # gestione permessi per casella (admin) │ │ │ ├── VirtualBoxes/ # gestione virtual box (admin) │ │ │ ├── Users/ # gestione utenti (admin) │ │ │ ├── Notifications/ # canali e regole notifiche (admin) │ │ │ ├── Archival/ # log versamenti + DIP │ │ │ └── Reports/ # dashboard + export │ │ │ │ │ ├── components/ ← UI atomici riusabili │ │ │ ├── PecBadge/ # badge stato PEC (Inviata/Accettata/Consegnata) │ │ │ ├── ReceiptTree/ # visualizza gerarchia ricevute EML-in-EML │ │ │ ├── AttachmentList/ │ │ │ ├── SearchBar/ # barra ricerca con suggerimenti │ │ │ ├── SearchFilters/ # pannello filtri avanzati (collapsible) │ │ │ ├── VirtualBoxBadge/ # indica se l'utente è in una VBox │ │ │ └── NotificationChannelIcon/ # icona canale (webhook/email/telegram/whatsapp) │ │ │ │ │ ├── hooks/ │ │ │ ├── useWebSocket.ts # Socket.io hook, aggiorna inbox in real-time │ │ │ ├── useAuth.ts │ │ │ ├── useSearch.ts # debounce, history, highlight risultati │ │ │ ├── usePermissions.ts # controlla permessi utente corrente │ │ │ └── usePagination.ts │ │ │ │ │ └── types/ ← SOLO tipi frontend-specific │ │ └── ui.types.ts │ │ │ └── tests/ │ ├── unit/ # Vitest + Testing Library │ └── e2e/ # Playwright │ ├── shared/ ═══════════════════════════════ │ │ ← Tipi/contratti condivisi tra frontend e backend │ │ Generati da OpenAPI schema o scritti a mano │ ├── types/ │ │ ├── api.types.ts # DTOs generati dall'OpenAPI spec del backend │ │ ├── pec.types.ts # enumerazioni stati PEC, tipi ricevute │ │ └── archival.types.ts # tipi SIP, RdV │ └── README.md │ ├── database/ ═══════════════════════════════ │ │ ← Script SQL stand-alone, seeds, fixtures │ ├── init/ │ │ └── 00_extensions.sql # CREATE EXTENSION pgcrypto, uuid-ossp │ ├── seeds/ │ │ ├── dev_tenant.sql # tenant e utente demo per sviluppo locale │ │ └── test_fixtures.sql │ └── backups/ # (gitignored) dump locali │ ├── infra/ ═══════════════════════════════ │ │ ← Infrastructure as Code │ ├── nginx/ │ │ ├── nginx.conf # reverse proxy, rate limiting, TLS termination │ │ └── conf.d/ │ │ └── pecflow.conf │ ├── redis/ │ │ └── redis.conf # maxmemory, eviction policy │ ├── prometheus/ │ │ └── prometheus.yml │ └── grafana/ │ └── dashboards/ │ └── pecflow.json │ └── docs/ ═══════════════════════════════ ├── api/ ← OpenAPI spec generata (non commitare auto-gen) ├── adr/ ← Architecture Decision Records │ ├── ADR-001-multitenancy.md │ ├── ADR-002-credential-encryption.md │ └── ADR-003-imap-concurrency.md └── diagrams/ ├── c4-context.png ├── c4-container.png └── db-schema.png ``` --- ## 2. Modello Dati ### Strategia multi-tenancy Si usa **row-level con `tenant_id`** su ogni tabella applicativa (vedi ADR-001 in sezione 4 per la motivazione completa). Le policy RLS PostgreSQL vengono attivate come secondo strato di difesa. ### DDL Completo ```sql -- ============================================================ -- ESTENSIONI -- ============================================================ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_bytes per nonce AES -- ============================================================ -- 1. TENANTS -- Ogni organizzazione cliente del SaaS -- ============================================================ CREATE TABLE tenants ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), slug VARCHAR(63) NOT NULL UNIQUE, -- usato come subdomain: acme.pecflow.it name VARCHAR(255) NOT NULL, plan VARCHAR(50) NOT NULL DEFAULT 'starter', -- starter|pro|enterprise is_active BOOLEAN NOT NULL DEFAULT TRUE, max_mailboxes INT NOT NULL DEFAULT 5, max_users INT NOT NULL DEFAULT 10, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_tenants_slug ON tenants (slug); -- ============================================================ -- 2. USERS -- ============================================================ CREATE TYPE user_role AS ENUM ('super_admin', 'admin', 'supervisor', 'operator', 'readonly'); CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, email VARCHAR(255) NOT NULL, password_hash TEXT NOT NULL, -- bcrypt, work factor 12 full_name VARCHAR(255) NOT NULL, role user_role NOT NULL DEFAULT 'operator', is_active BOOLEAN NOT NULL DEFAULT TRUE, totp_secret TEXT, -- cifrato con app key totp_enabled BOOLEAN NOT NULL DEFAULT FALSE, last_login_at TIMESTAMPTZ, failed_login_count INT NOT NULL DEFAULT 0, locked_until TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_user_email_tenant UNIQUE (tenant_id, email) ); CREATE INDEX idx_users_tenant ON users (tenant_id); CREATE INDEX idx_users_email ON users (email); -- RLS: un utente vede solo gli utenti del proprio tenant ALTER TABLE users ENABLE ROW LEVEL SECURITY; CREATE POLICY users_tenant_isolation ON users USING (tenant_id = current_setting('app.current_tenant_id')::UUID); -- ============================================================ -- 3. REFRESH TOKENS -- ============================================================ CREATE TABLE refresh_tokens ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, -- SHA-256 del token raw issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, user_agent TEXT, ip_address INET ); CREATE INDEX idx_rt_user ON refresh_tokens (user_id); CREATE INDEX idx_rt_expires ON refresh_tokens (expires_at) WHERE revoked_at IS NULL; -- ============================================================ -- 4. MAILBOXES (caselle PEC) -- ============================================================ CREATE TYPE mailbox_status AS ENUM ('active', 'paused', 'error', 'deleted'); CREATE TABLE mailboxes ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, email_address VARCHAR(255) NOT NULL, display_name VARCHAR(255), provider VARCHAR(100), -- 'aruba', 'namirial', 'legalmail', ecc. -- credenziali IMAP/SMTP cifrate (AES-256-GCM) -- formato colonna: base64(nonce || ciphertext || tag) imap_host_enc TEXT NOT NULL, imap_port_enc TEXT NOT NULL, imap_user_enc TEXT NOT NULL, imap_pass_enc TEXT NOT NULL, imap_use_ssl BOOLEAN NOT NULL DEFAULT TRUE, smtp_host_enc TEXT NOT NULL, smtp_port_enc TEXT NOT NULL, smtp_user_enc TEXT NOT NULL, smtp_pass_enc TEXT NOT NULL, smtp_use_tls BOOLEAN NOT NULL DEFAULT TRUE, status mailbox_status NOT NULL DEFAULT 'active', last_sync_at TIMESTAMPTZ, last_sync_uid BIGINT, -- ultimo UID IMAP sincronizzato sync_error_msg TEXT, sync_error_count INT NOT NULL DEFAULT 0, created_by UUID REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_mailbox_email_tenant UNIQUE (tenant_id, email_address) ); CREATE INDEX idx_mailboxes_tenant ON mailboxes (tenant_id); CREATE INDEX idx_mailboxes_status ON mailboxes (status) WHERE status = 'active'; ALTER TABLE mailboxes ENABLE ROW LEVEL SECURITY; CREATE POLICY mailboxes_tenant_isolation ON mailboxes USING (tenant_id = current_setting('app.current_tenant_id')::UUID); -- ============================================================ -- 5. MESSAGES (PEC ricevute e inviate) -- ============================================================ CREATE TYPE pec_direction AS ENUM ('inbound', 'outbound'); CREATE TYPE pec_state AS ENUM ( -- outbound flow 'draft', 'queued', 'sent', 'accepted', 'delivered', 'anomaly', 'failed', -- inbound 'received' ); CREATE TYPE pec_msg_type AS ENUM ( 'posta_certificata', -- messaggio originale PEC 'accettazione', -- ricevuta di accettazione dal gestore mittente 'non_accettazione', -- ricevuta di non accettazione 'presa_in_carico', -- ricevuta intermedia alcuni provider 'avvenuta_consegna', -- ricevuta di avvenuta consegna dal gestore destinatario 'mancata_consegna', -- ricevuta di mancata consegna (timeout / errore) 'errore_consegna', -- ricevuta di errore consegna 'preavviso_mancata_consegna', 'rilevazione_virus', 'unknown' ); CREATE TABLE messages ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, -- identificatori message_id_header TEXT, -- valore header Message-ID imap_uid BIGINT, -- UID IMAP nella casella imap_folder VARCHAR(255) NOT NULL DEFAULT 'INBOX', direction pec_direction NOT NULL, pec_type pec_msg_type NOT NULL DEFAULT 'posta_certificata', state pec_state NOT NULL, -- busta PEC subject TEXT, from_address VARCHAR(255), to_addresses TEXT[], -- array destinatari cc_addresses TEXT[], sent_at TIMESTAMPTZ, -- data invio originale received_at TIMESTAMPTZ, -- data ricezione IMAP size_bytes BIGINT, -- corpo body_text TEXT, -- testo plain estratto body_html TEXT, -- HTML estratto (solo per visualizzazione) has_attachments BOOLEAN NOT NULL DEFAULT FALSE, -- per messaggi outbound: riferimento alla PEC originale di cui sono ricevuta parent_message_id UUID REFERENCES messages(id), -- flag operativi is_read BOOLEAN NOT NULL DEFAULT FALSE, is_starred BOOLEAN NOT NULL DEFAULT FALSE, is_archived BOOLEAN NOT NULL DEFAULT FALSE, archived_at TIMESTAMPTZ, -- raw storage path (allegati e raw EML) raw_eml_path TEXT, -- percorso in object storage created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_messages_tenant ON messages (tenant_id); CREATE INDEX idx_messages_mailbox ON messages (mailbox_id); CREATE INDEX idx_messages_state ON messages (state); CREATE INDEX idx_messages_received_at ON messages (received_at DESC); CREATE INDEX idx_messages_parent ON messages (parent_message_id) WHERE parent_message_id IS NOT NULL; CREATE INDEX idx_messages_imap_uid ON messages (mailbox_id, imap_uid); -- Full-text search sul soggetto CREATE INDEX idx_messages_subject_fts ON messages USING GIN (to_tsvector('italian', COALESCE(subject, ''))); ALTER TABLE messages ENABLE ROW LEVEL SECURITY; CREATE POLICY messages_tenant_isolation ON messages USING (tenant_id = current_setting('app.current_tenant_id')::UUID); -- ============================================================ -- 6. ATTACHMENTS -- ============================================================ CREATE TABLE attachments ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, filename VARCHAR(512) NOT NULL, content_type VARCHAR(255), size_bytes BIGINT, storage_path TEXT NOT NULL, -- percorso MinIO/S3: tenants/{tid}/msgs/{mid}/{filename} checksum_sha256 CHAR(64), -- integrità created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_attachments_message ON attachments (message_id); -- ============================================================ -- 7. SEND_JOBS (tracciamento invii SMTP) -- ============================================================ CREATE TYPE send_job_status AS ENUM ('pending', 'sending', 'sent', 'failed', 'retrying'); CREATE TABLE send_jobs ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, mailbox_id UUID NOT NULL REFERENCES mailboxes(id), message_id UUID REFERENCES messages(id), -- NULL fino all'invio riuscito status send_job_status NOT NULL DEFAULT 'pending', attempt_count INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 5, next_retry_at TIMESTAMPTZ, last_error TEXT, queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), sent_at TIMESTAMPTZ, created_by UUID REFERENCES users(id) ); CREATE INDEX idx_sendjobs_tenant ON send_jobs (tenant_id); CREATE INDEX idx_sendjobs_status ON send_jobs (status, next_retry_at) WHERE status IN ('pending','retrying'); -- ============================================================ -- 8. ARCHIVAL_BATCHES (versamenti verso conservatore AgID) -- ============================================================ CREATE TYPE archival_status AS ENUM ( 'pending', -- in coda 'building_sip', -- generazione pacchetto SIP 'uploading', -- trasferimento al conservatore 'uploaded', -- ricevuta di versamento ricevuta (RdV) 'confirmed', -- conservatore ha confermato presa in carico 'rejected', -- conservatore ha rifiutato il versamento 'failed' -- errore tecnico non recuperabile ); CREATE TABLE archival_batches ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, conservatore_id VARCHAR(100) NOT NULL, -- codice conservatore (es. 'aruba-cons', 'docuvision') status archival_status NOT NULL DEFAULT 'pending', sip_path TEXT, -- path del pacchetto SIP generato sip_checksum CHAR(64), -- SHA-256 del SIP -- risposta dal conservatore versamento_id TEXT, -- ID assegnato dal conservatore rdv_received_at TIMESTAMPTZ, rdv_path TEXT, -- path della RdV archiviata rdv_checksum CHAR(64), attempt_count INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 3, next_retry_at TIMESTAMPTZ, last_error TEXT, period_from DATE NOT NULL, -- periodo di riferimento del versamento period_to DATE NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_archival_tenant ON archival_batches (tenant_id); CREATE INDEX idx_archival_status ON archival_batches (status, next_retry_at); -- Tabella pivot: quali messaggi sono inclusi in quale batch CREATE TABLE archival_batch_messages ( batch_id UUID NOT NULL REFERENCES archival_batches(id) ON DELETE CASCADE, message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, PRIMARY KEY (batch_id, message_id) ); -- DIP (Dissemination Information Package): recupero da conservatore CREATE TABLE archival_dips ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, batch_id UUID REFERENCES archival_batches(id), requested_by UUID REFERENCES users(id), dip_path TEXT, status VARCHAR(50) NOT NULL DEFAULT 'requested', requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), received_at TIMESTAMPTZ ); -- ============================================================ -- 9. AUDIT_LOG -- Immutabile (no UPDATE/DELETE via applicazione) -- ============================================================ CREATE TABLE audit_log ( id BIGSERIAL PRIMARY KEY, tenant_id UUID REFERENCES tenants(id), user_id UUID REFERENCES users(id), action VARCHAR(100) NOT NULL, -- 'message.read', 'mailbox.create', ecc. resource_type VARCHAR(100), resource_id UUID, ip_address INET, user_agent TEXT, payload JSONB, -- parametri dell'azione (sanitizzati) outcome VARCHAR(20) NOT NULL DEFAULT 'success', -- success|failure occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Partizionamento per range (mese) per gestire volumi grandi senza purge -- In Postgres 14+ si può fare: PARTITION BY RANGE (occurred_at) -- Per semplicità in v1, si aggiunge un indice composito CREATE INDEX idx_audit_tenant_date ON audit_log (tenant_id, occurred_at DESC); CREATE INDEX idx_audit_user ON audit_log (user_id, occurred_at DESC); CREATE INDEX idx_audit_action ON audit_log (action); -- Blocca UPDATE/DELETE anche per DBA (si può bypassare solo con superuser esplicito) ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY; CREATE POLICY audit_no_delete ON audit_log FOR DELETE USING (FALSE); CREATE POLICY audit_no_update ON audit_log FOR UPDATE USING (FALSE); CREATE POLICY audit_tenant_read ON audit_log FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id')::UUID); -- ============================================================ -- 10. LABELS / ETICHETTE (tagging messaggi) -- ============================================================ CREATE TABLE labels ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, color CHAR(7), -- hex color #RRGGBB CONSTRAINT uq_label_name_tenant UNIQUE (tenant_id, name) ); CREATE TABLE message_labels ( message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY (message_id, label_id) ); -- ============================================================ -- TRIGGER: updated_at automatico -- ============================================================ CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$; DO $$ DECLARE t TEXT; BEGIN FOREACH t IN ARRAY ARRAY['tenants','users','mailboxes','messages','archival_batches'] LOOP EXECUTE format('CREATE TRIGGER trg_%s_updated_at BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION set_updated_at()', t, t); END LOOP; END $$; ``` --- ## 3. Piano di Sviluppo a Fasi ### Fase 1 – Fondamenta (3 settimane) **Obiettivo:** infrastruttura funzionante, autenticazione sicura, deploy locale. **Task:** - [ ] Inizializzare monorepo con struttura cartelle, `.gitignore`, `Makefile` - [ ] `docker-compose.yml` con PostgreSQL 16, Redis 7, MinIO (S3 local), Nginx - [ ] Backend FastAPI: struttura app, config pydantic-settings, health endpoint - [ ] Alembic: prima migrazione con tutte le tabelle DDL (sezione 2) - [ ] Modello `tenants` + `users` + ORM SQLAlchemy corrispondente - [ ] Auth API: `POST /auth/login` (email+password), JWT access token (15 min) + refresh token (30 gg) - [ ] Middleware RLS: `set_config('app.current_tenant_id', ...)` su ogni request - [ ] API 2FA TOTP: `POST /auth/totp/setup`, `POST /auth/totp/verify` - [ ] Rate limiting su endpoint auth (slowapi) - [ ] CRUD utenti base (admin crea/disabilita utenti dello stesso tenant) - [ ] CI GitHub Actions: lint (ruff, mypy), test (pytest), build Docker - [ ] Seed script: tenant demo + utente admin per dev locale **Definition of Done:** - Login, refresh token e 2FA funzionanti e coperti da test di integrazione - `make dev` porta in piedi tutto lo stack locale - CI verde su PR --- ### Fase 2 – IMAP Sync Engine (4 settimane) **Obiettivo:** connessione IMAP stabile a N caselle in parallelo, sincronizzazione messaggi. **Task:** - [ ] CRUD caselle PEC (`POST /mailboxes`, cifratura credenziali AES-256-GCM) - [ ] Worker arq: entrypoint, connessione Redis, worker health check - [ ] `MailboxPool`: avvia un task async per ogni casella `status=active` all'avvio - [ ] `IMAPConnection`: connessione IMAP via aioimaplib, gestione IDLE con heartbeat 28 min (RFC 2177) - [ ] Polling fallback: se IDLE non supportato, polling ogni 60 secondi - [ ] Backoff esponenziale su disconnessione (1s → 2s → 4s → max 5 min) - [ ] Fetch UID list + envelope per messaggi nuovi (UID > `last_sync_uid`) - [ ] Salvataggio `messages` con campi base, aggiornamento `last_sync_uid` - [ ] Download body e raw EML su MinIO: `tenants/{tid}/raw/{mailbox_id}/{uid}.eml` - [ ] Aggiornamento stato casella: `sync_error_count`, `status=error` dopo N fallimenti - [ ] Notifica WebSocket su nuovo messaggio: evento `mailbox:new_message` al tenant - [ ] Test di integrazione con Greenmail (server IMAP mock Java) via Docker **Definition of Done:** - Connessione a casella Aruba PEC reale in ambiente di test (sandbox) - Verifica leggibilità mail - Riconnessione automatica verificata (kill connessione di rete e attesa recovery) --- ### Fase 3 – Parser PEC & Tracking Ricevute (3 settimane) **Obiettivo:** classificare correttamente i messaggi PEC e collegare le ricevute. **Task:** - [ ] `pec_parser.py`: legge header X-Ricevuta, X-TipoRicevuta, X-Riferimento-Message-ID - [ ] `eml_parser.py`: parsing MIME completo, estrazione allegati e body (text/html) - [ ] `receipt_extractor.py`: estrae EML allegato dentro ricevuta (EML-in-EML annidato) - [ ] Mappatura `pec_msg_type` da header PEC a enum DB - [ ] Collegamento `parent_message_id`: associa ricevuta al messaggio originale via `X-Riferimento-Message-ID` - [ ] State machine messaggi outbound: `sent→accepted→delivered` (o `anomaly`) - [ ] Download e salvataggio allegati su MinIO, inserimento tabella `attachments` - [ ] Test unitari parser con EML reali (fixture anonimizzate) per tutti i tipi di ricevuta - [ ] Test regressione: parsing Aruba / Namirial / Legalmail (formato header leggermente diverso per provider) **Definition of Done:** - 100% dei tipi ricevuta classificati correttamente su un set di 50+ EML reali - I messaggi outbound aggiornano stato automaticamente all'arrivo della ricevuta --- ### Fase 4 – Invio SMTP (2 settimane) **Obiettivo:** invio PEC affidabile con retry e tracking. **Task:** - [ ] API `POST /send`: validazione, creazione `send_job` e `message` in stato `draft`→`queued` - [ ] Job `send_pec`: connessione SMTP STARTTLS/SSL via aiosmtplib - [ ] Gestione `To`, `Cc` multipli, allegati, headers PEC obbligatori - [ ] Salvataggio raw EML inviato su MinIO - [ ] Retry con backoff: 5 tentativi (1 min → 5 min → 15 min → 1h → 4h) - [ ] Dopo invio: avvia `receipt_watcher` – attende ricevuta di accettazione entro 24h - [ ] Alert: se nessuna accettazione in 24h → stato `anomaly` + notifica WS **Definition of Done:** - Invio PEC funzionante verso casella test con ricevuta di accettazione verificata - Retry verificato simulando errore SMTP temporaneo --- ### Fase 5 – Frontend (5 settimane) **Obiettivo:** interfaccia utente completa per operatori e admin. **Task:** - [ ] Setup Vite + React 18 + TypeScript + Tailwind CSS + shadcn/ui - [ ] Layout base: sidebar navigazione, header tenant - [ ] Pagina Login + 2FA TOTP (QR code, OTP input) - [ ] Axios client: interceptor refresh token (refresh silenzioso su 401) - [ ] Hook `useWebSocket` (Socket.io): aggiorna inbox in real-time - [ ] Inbox: lista messaggi paginata, filtri (casella, stato, etichetta, testo), badge PEC state - [ ] Dettaglio messaggio: corpo, allegati scaricabili, tree ricevute (componente `ReceiptTree`) - [ ] Composizione PEC: form To/Cc/Subject/Body/Allegati, preview, invio - [ ] Gestione Caselle (admin): CRUD, test connessione, stato sync - [ ] Gestione Utenti (admin): CRUD, cambio ruolo, reset password - [ ] Etichette: crea/assegna/filtra - [ ] Internazionalizzazione: i18next (solo italiano per v1) - [ ] E2E Playwright: login, lettura PEC, invio PEC **Definition of Done:** - Demo completa con un operatore che riceve e invia PEC senza toccare l'API direttamente - Lighthouse score ≥ 80 su Performance e Accessibility --- ### Fase 6 – Archiviazione Sostitutiva (4 settimane) **Obiettivo:** versamento PEC verso conservatore AgID, recupero DIP. **Task:** - [ ] `sip_builder.py`: generazione pacchetto SIP conforme UNI SInCRO 11386:2023 - Indice del pacchetto (XML) - Allegati originali + ricevute - Calcolo hash SHA-256 per ogni file - [ ] `conservatore_client.py`: client HTTP per API conservatore (mock locale in dev) - `POST /versamento` (upload SIP) - `GET /versamento/{id}` (polling stato) - `GET /dip/{id}` (richiesta DIP) - [ ] `rdv_processor.py`: parsing RdV XML, salvataggio in `archival_batches` - [ ] Job `archive_batch`: seleziona messaggi `archived=false` nel periodo, crea SIP, carica - [ ] Retry versamenti falliti: 3 tentativi con backoff progressivo - [ ] API `GET /archival/batches`, `GET /archival/batches/{id}/rdv` - [ ] API `POST /archival/dip` (richiesta DIP) - [ ] Pagina frontend Archivio: log versamenti, download RdV, richiesta DIP - [ ] Configurazione per-tenant del conservatore (endpoint, credenziali cifrate) **Definition of Done:** - Versamento completato end-to-end verso conservatore mock - RdV XML salvata e scaricabile dal frontend --- ### Fase 7 – Dashboard e Reportistica (2 settimane) **Obiettivo:** visibilità operativa e compliance. **Task:** - [ ] API `/reports/summary`: KPI real-time (PEC ricevute/inviate oggi, anomalie, tasso consegna) - [ ] API `/reports/export`: generazione PDF (WeasyPrint) e CSV - [ ] Dashboard frontend: grafici (Recharts), filtri per periodo e casella - [ ] Audit log viewer (admin): ricerca per utente/azione/data - [ ] Alerting: soglie configurabili (es. >10% anomalie → email notifica admin) **Definition of Done:** - Export PDF generato correttamente con dati reali - Dashboard mostra dati aggiornati in < 2s --- ### Fase 8 – Hardening, Test, Go-Live (3 settimane) **Obiettivo:** sistema production-ready. **Task:** - [ ] Penetration test interno: OWASP Top 10 check (IDOR, injection, broken auth) - [ ] Secret scan CI: Gitleaks / Trufflesecurity su ogni commit - [ ] Dependency audit: `pip audit`, `npm audit` → zero critical - [ ] Copertura test: ≥ 80% backend, ≥ 60% frontend - [ ] Load test: Locust – 50 utenti concorrenti, 10 caselle sincronizzate - [ ] Backup automatico PostgreSQL (pg_dump daily → MinIO, retention 30gg) - [ ] Monitoring: Prometheus + Grafana dashboard (latency API, queue size, IMAP errors) - [ ] Log aggregation: Loki o ELK stack - [ ] Runbook operativo: come aggiungere tenant, debug IMAP, recovery da errore conservatore - [ ] GDPR: endpoint `DELETE /tenants/{id}` (cancellazione dati completa + audit trail) **Definition of Done:** - Zero vulnerability critiche - SLA target: API p95 < 500ms, uptime 99.5% - Runbook revisionato dal team --- ## 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 **Opzione A – Cifratura applicativa (AES-256-GCM)** Le credenziali sono cifrate nel layer applicativo prima di essere scritte in DB. La chiave master è in `SECRET_KEY` (variabile d'ambiente, ruotabile). Formato storage: `base64(nonce_12byte || ciphertext || tag_16byte)`. *Pro:* la chiave non è mai in DB; un dump del DB senza la chiave è inutile. *Contro:* rotazione chiave richiede re-cifratura di tutte le righe. **Opzione B – pgcrypto `pgp_sym_encrypt`** Cifratura dentro PostgreSQL con passphrase. *Pro:* semplice da implementare. *Contro:* la chiave transita sulla connessione DB; meno controllo sul key management. **Opzione C – HashiCorp Vault / AWS KMS (envelope encryption)** Vault genera una data encryption key (DEK) per ogni casella; la DEK è cifrata con key encryption key (KEK) gestita da Vault. *Pro:* key rotation automatica, audit trail accessi chiave, compliance elevata. *Contro:* dipendenza infrastrutturale aggiuntiva, complessità setup per v1. **Raccomandazione: Opzione A per v1, con migrazione verso C in Fase 8** In v1 si usa AES-256-GCM con chiave in variabile d'ambiente (gestita via Docker Secret o Kubernetes Secret). L'interfaccia di cifratura è astratta in `core/security.py`, quindi la migrazione a Vault è trasparente al resto del codice. La rotazione della chiave viene gestita con un management command dedicato. --- ### ADR-003 – Concorrenza IMAP su N caselle **Opzione A – Un processo per casella (multiprocessing)** Un processo OS per ogni casella PEC attiva. *Pro:* isolamento totale; crash di una casella non impatta le altre. *Contro:* overhead di memoria elevato (N × ~50MB), difficile scalare oltre ~50 caselle per host. **Opzione B – Thread pool** Un thread per casella dentro un unico processo Python. *Contro:* GIL Python limita parallelismo CPU-bound; librerie IMAP non sempre thread-safe. **Opzione C – Async task pool (asyncio) – RACCOMANDATO** Un singolo processo async con N coroutine IMAP, una per casella. La libreria `aioimaplib` è nativa async. Ogni casella gira in un loop IDLE / polling come coroutine indipendente. *Pro:* memory footprint bassissimo (< 10MB per casella), migliaia di caselle per host, codice semplice. *Contro:* un bug che blocca l'event loop impatta tutte le caselle → mitigato con timeout su ogni operazione IMAP. **Implementazione consigliata:** ``` MailboxPool (asyncio) ├── asyncio.Task per casella #1 → IMAPConnection (IDLE loop) ├── asyncio.Task per casella #2 → IMAPConnection (IDLE loop) └── ... ``` Il `MailboxPool` monitora ogni task con `asyncio.shield` + timeout. Se un task muore, viene riavviato con backoff esponenziale. Un semaforo limita il numero di fetch simultanei (es. max 10) per evitare saturazione banda. --- ### ADR-004 – Retry strategy versamenti verso conservatore AgID Il conservatore AgID può essere temporaneamente non disponibile (manutenzione, overload). Un versamento fallito non è recuperabile in modo autonomo dal conservatore: il sistema deve ritentare attivamente. **Strategia:** 1. **Backoff esponenziale con jitter:** tentativo 1 subito, poi 1h, 4h, 24h (max 3 retry oltre il primo) 2. **Dead letter queue:** dopo tutti i retry falliti, il batch va in stato `failed` e genera un alert email all'admin del tenant + al team ops PecFlow 3. **Idempotenza:** prima di ogni retry, verificare se il conservatore ha già ricevuto il versamento (`GET /versamento/{id}`) per evitare duplicati 4. **Finestra di versamento:** i versamenti periodici (es. mensili) devono completarsi entro la scadenza normativa; se il sistema prevede che non ce la farà, genera alert anticipato a 72h dalla scadenza 5. **Circuit breaker:** se il conservatore fallisce 5 volte in 1 ora, si sospendono tutti i versamenti verso quel conservatore per 30 minuti (evita tempesta di retry) --- ### ADR-005 – Storage allegati e raw EML **Opzione A – Filesystem locale** *Pro:* zero dipendenze, semplicissimo. *Contro:* non scala orizzontalmente (worker su host diversi non vedono i file); backup complesso. **Opzione B – PostgreSQL BYTEA / Large Object** *Contro:* gonfia il DB, WAL enorme, backup lenti, nessun vantaggio rispetto a object storage. **Opzione C – Object Storage S3-compatible (MinIO in locale, S3/Wasabi in prod)** *Pro:* scala infinitamente, separazione DB/oggetti, backup indipendente, URL pre-firmati per download diretto dal browser senza passare per il backend, CDN-ready. *Contro:* dipendenza aggiuntiva (MinIO in Docker Compose per dev). **Raccomandazione: Opzione C** Percorso oggetto: `{bucket}/tenants/{tenant_id}/mailboxes/{mailbox_id}/messages/{message_id}/{tipo}/{filename}` Dove `{tipo}` può essere `raw` (EML grezzo), `attachments`, `sip` (pacchetti SIP), `rdv`. I metadati (filename, content-type, checksum, path) sono in PostgreSQL nella tabella `attachments`. Il frontend riceve URL pre-firmati con scadenza 15 minuti per il download diretto. In ambiente di sviluppo locale, MinIO è incluso nel `docker-compose.yml` con credenziali fisse. In produzione si sostituisce con la variabile `S3_ENDPOINT_URL` che punta a S3 o a un provider compatibile. --- ### ADR-006 – Strategia Full-Text Search **Opzione A – PostgreSQL FTS nativo (tsvector/tsquery)** Colonna `search_vector` di tipo `tsvector` aggiornata tramite trigger ad ogni INSERT/UPDATE su `messages`. Copre: oggetto, corpo, mittente, destinatari, nomi allegati. *Pro:* zero dipendenze aggiuntive, stesso DB già in stack, transazionale, indice GIN incluso nel backup. *Contro:* ricerca su testo estratto dagli allegati (PDF, DOCX) richiede pre-processing separato; non scala quanto Elasticsearch su miliardi di documenti. **Opzione B – Elasticsearch / OpenSearch** Servizio dedicato con indici invertiti ottimizzati, supporto nativo a highlighting, fuzzy, suggerimenti. *Pro:* performance eccellente su grandi volumi, relevance scoring avanzato, analizzatori linguistici. *Contro:* servizio aggiuntivo da gestire (heap JVM, snapshot, upgrade), sincronizzazione DB→ES può andare out-of-sync, licenza cambiata (OpenSearch è alternativa open). **Raccomandazione: Opzione A per v1-v2, revisione a v3** La ricerca FTS nativa di PostgreSQL è più che sufficiente per volumi tipici di PEC aziendali (< 5 milioni di messaggi). Il `search_vector` copre tutti i campi principali; il testo degli allegati viene estratto in background (Apache Tika via container Docker dedicato) e aggiunto al vettore. La migrazione a Elasticsearch in futuro è possibile senza modificare l'API pubblica, solo il `search_service.py`. --- ### ADR-007 – Architettura notifiche multi-canale **Problema:** i canali di notifica (webhook, email, Telegram, WhatsApp) hanno latenza, affidabilità e costi di chiamata completamente diversi. Un fallimento su un canale non deve bloccare gli altri. **Architettura scelta: Fan-out asincrono per canale con retry indipendenti** ``` Evento PEC (es. message.received) ↓ notification_service.evaluate_rules() ← trova regole applicabili ↓ Per ogni regola corrispondente: → enqueue job dispatch_notification (canale, payload, rule_id) ↓ worker/notifications/dispatcher.py ├── webhook.py → HTTP POST + HMAC-SHA256 signature header ├── email_smtp.py → template HTML via aiosmtplib ├── telegram.py → Bot API sendMessage (MarkdownV2) └── whatsapp.py → Meta Cloud API v18 / Twilio fallback ↓ notification_log (outcome: success|failed, attempt_count) ``` Ogni dispatch è un job arq indipendente con retry (max 3, backoff 5 min → 30 min → 2h). Il fallimento di Telegram non ritarda il webhook. Ogni canale ha il proprio circuit breaker (5 fail / 10 min → pausa 1h). **WhatsApp:** si usa Meta Cloud API (gratuita fino a 1000 conversazioni/mese) con fallback opzionale su Twilio per volumi maggiori. Le credenziali (token, phone_number_id) sono configurate per-tenant in `notification_channels`. **Telegram:** un bot per tenant (token nel canale) o bot condiviso con `chat_id` per-utente. Si raccomanda bot condiviso per semplicità operativa in v1. --- ### ADR-008 – Strategia permessi e isolamento Virtual Box **Problema:** il sistema deve supportare sia accesso globale (admin vede tutto) sia accesso ristretto granulare (utente vede solo mail specifiche da casella specifica). **Strategia a due livelli:** **Livello 1 – Permessi per casella (`mailbox_permissions`)** Ogni utente può avere permessi espliciti su una casella: `can_read`, `can_send`, `can_manage`. Gli admin hanno accesso implicito a tutte le caselle del proprio tenant. Gli operatori senza permessi espliciti non vedono nessuna casella. **Livello 2 – Virtual Box (filtro sui messaggi)** Una Virtual Box è un filtro configurabile che, se assegnato a un utente, restringe ulteriormente i messaggi visibili *dentro* le caselle permesse. I criteri sono combinati in AND: l'utente vede solo i messaggi che soddisfano TUTTI i criteri della VBox assegnata. ``` Utente pbianchi ├── mailbox_permissions: info@ → can_read=TRUE, can_send=FALSE └── virtual_box_assignment: "Multe info@" └── virtual_box_rules: ├── mailbox_id = └── subject_pattern = 'Multa' (ILIKE '%Multa%') → pbianchi vede SOLO i messaggi di info@ con oggetto contenente "Multa" ``` L'isolamento è applicato nel `virtual_box_service.py` che inietta le clausole WHERE aggiuntive in ogni query messaggi. La stessa logica si applica alla ricerca avanzata: se l'utente ha una VBox, la ricerca non può uscire dal perimetro della VBox. --- ## 2.2 DDL Aggiuntivo – Sistemi Avanzati ```sql -- ============================================================ -- A. SEARCH VECTOR su MESSAGES -- Colonna calcolata per full-text search su tutti i campi -- ============================================================ -- Aggiunta della colonna search_vector alla tabella messages ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector TSVECTOR GENERATED ALWAYS AS ( setweight(to_tsvector('italian', COALESCE(subject, '')), 'A') || setweight(to_tsvector('italian', COALESCE(from_address, '')), 'B') || setweight(to_tsvector('italian', array_to_string(COALESCE(to_addresses, '{}'), ' ')), 'B') || setweight(to_tsvector('italian', COALESCE(body_text, '')), 'C') ) STORED; CREATE INDEX idx_messages_search_vector ON messages USING GIN (search_vector); -- Aggiunta testo estratto degli allegati (aggiornato dal worker Tika) ALTER TABLE attachments ADD COLUMN IF NOT EXISTS extracted_text TEXT; ALTER TABLE attachments ADD COLUMN IF NOT EXISTS extraction_status VARCHAR(20) DEFAULT 'pending'; -- extraction_status: pending | done | skipped (non testuale) | failed CREATE INDEX idx_attachments_fts ON attachments USING GIN (to_tsvector('italian', COALESCE(extracted_text, ''))) WHERE extracted_text IS NOT NULL; -- ============================================================ -- B. PERMESSI GRANULARI PER CASELLA -- ============================================================ -- Matrice permessi: per ogni (user, mailbox) definisce cosa può fare. -- Un admin di tenant NON ha bisogno di record: accesso implicito. -- Operatori e readonly DEVONO avere un record per vedere la casella. CREATE TABLE mailbox_permissions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, can_read BOOLEAN NOT NULL DEFAULT TRUE, -- legge messaggi can_send BOOLEAN NOT NULL DEFAULT FALSE, -- invia PEC da questa casella can_manage BOOLEAN NOT NULL DEFAULT FALSE, -- modifica configurazione casella granted_by UUID REFERENCES users(id), granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_perm_user_mailbox UNIQUE (user_id, mailbox_id) ); CREATE INDEX idx_mbperm_user ON mailbox_permissions (user_id); CREATE INDEX idx_mbperm_mailbox ON mailbox_permissions (mailbox_id); CREATE INDEX idx_mbperm_tenant ON mailbox_permissions (tenant_id); -- ============================================================ -- C. VIRTUAL BOX -- ============================================================ -- Definizione di una Virtual Box (vista filtrata sui messaggi) CREATE TABLE virtual_boxes ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, description TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_by UUID REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_vbox_name_tenant UNIQUE (tenant_id, name) ); CREATE INDEX idx_vbox_tenant ON virtual_boxes (tenant_id); -- Regole di filtro per una Virtual Box. -- Tutte le regole valide di una VBox sono combinate in AND. -- Campi NULL = "non filtrare su questo criterio". CREATE TABLE virtual_box_rules ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), virtual_box_id UUID NOT NULL REFERENCES virtual_boxes(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, -- Filtri strutturali (NULL = qualsiasi) mailbox_id UUID REFERENCES mailboxes(id) ON DELETE CASCADE, imap_folder VARCHAR(255), -- es. 'INBOX', 'Sent' direction pec_direction, -- inbound | outbound | NULL pec_type pec_msg_type, -- filtra per tipo PEC -- Filtri testuali (ILIKE '%pattern%' case-insensitive, NULL = qualsiasi) subject_pattern TEXT, -- es. 'Multa' from_pattern TEXT, -- es. 'comune.roma.it' to_pattern TEXT, -- filtra su to_addresses -- Filtro temporale date_from DATE, -- messaggi ricevuti da questa data date_to DATE, -- Filtro su etichetta label_id UUID REFERENCES labels(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_vbox_rules_vbox ON virtual_box_rules (virtual_box_id); -- Assegnazione utenti alle Virtual Box -- Un utente può essere assegnato a più VBox (in OR tra loro, vede l'unione) CREATE TABLE virtual_box_assignments ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, virtual_box_id UUID NOT NULL REFERENCES virtual_boxes(id) ON DELETE CASCADE, assigned_by UUID REFERENCES users(id), assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_vbox_assign UNIQUE (user_id, virtual_box_id) ); CREATE INDEX idx_vbox_assign_user ON virtual_box_assignments (user_id); CREATE INDEX idx_vbox_assign_vbox ON virtual_box_assignments (virtual_box_id); -- ============================================================ -- D. NOTIFICHE -- ============================================================ CREATE TYPE notification_channel_type AS ENUM ('webhook', 'email_smtp', 'telegram', 'whatsapp'); CREATE TYPE notification_event_type AS ENUM ( 'message.received', -- nuova PEC ricevuta 'message.delivered', -- PEC outbound consegnata 'message.anomaly', -- PEC anomalia (mancata consegna, virus, ecc.) 'message.failed', -- invio fallito definitivamente 'mailbox.error', -- casella in stato error 'archival.completed', -- versamento conservatore completato 'archival.failed', -- versamento fallito 'mailbox.quota_warning' -- casella quasi piena (stima da sync) ); -- Canali di notifica configurati per tenant (o per singolo utente) CREATE TABLE notification_channels ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id), -- NULL = canale condiviso del tenant name VARCHAR(255) NOT NULL, type notification_channel_type NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Configurazione specifica per tipo (cifrata se contiene segreti) -- webhook: { "url": "https://...", "secret": "hmac_key" } -- email_smtp: { "to": ["addr1","addr2"] } -- telegram: { "bot_token_enc": "...", "chat_id": "123456" } -- whatsapp: { "phone_number_id": "...", "access_token_enc": "...", "to": "+39..." } config_enc JSONB NOT NULL, -- AES-256-GCM su campi sensibili created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_notif_ch_tenant ON notification_channels (tenant_id); -- Regole: quali eventi triggherano quale canale, con filtri opzionali CREATE TABLE notification_rules ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE, event_type notification_event_type NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Filtri opzionali (NULL = qualsiasi) mailbox_id UUID REFERENCES mailboxes(id), -- solo per questa casella from_pattern TEXT, -- mittente ILIKE '%pattern%' subject_pattern TEXT, -- oggetto ILIKE '%pattern%' pec_type pec_msg_type, -- solo questo tipo PEC created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_notif_rule_tenant ON notification_rules (tenant_id); CREATE INDEX idx_notif_rule_channel ON notification_rules (channel_id); CREATE INDEX idx_notif_rule_event ON notification_rules (event_type); -- Log delle notifiche inviate CREATE TABLE notification_log ( id BIGSERIAL PRIMARY KEY, tenant_id UUID NOT NULL REFERENCES tenants(id), rule_id UUID REFERENCES notification_rules(id), channel_id UUID REFERENCES notification_channels(id), message_id UUID REFERENCES messages(id), -- messaggio che ha triggerato event_type notification_event_type NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending|sent|failed attempt_count INT NOT NULL DEFAULT 0, last_error TEXT, payload JSONB, -- payload inviato (per debug) sent_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_notif_log_tenant ON notification_log (tenant_id, created_at DESC); CREATE INDEX idx_notif_log_status ON notification_log (status) WHERE status = 'pending'; -- ============================================================ -- Estendi trigger set_updated_at ai nuovi modelli -- ============================================================ DO $$ DECLARE t TEXT; BEGIN FOREACH t IN ARRAY ARRAY['virtual_boxes','notification_channels'] LOOP EXECUTE format('CREATE TRIGGER trg_%s_updated_at BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION set_updated_at()', t, t); END LOOP; END $$; ``` --- ## 3.B Piano di Sviluppo – Fasi Aggiuntive (Sistemi Avanzati) Le seguenti fasi si inseriscono nel piano originale. La **Fase 1-A** è contestuale alla Fase 1, le altre possono essere sviluppate in parallelo al frontend (Fase 5). --- ### Fase 1-A – Permessi Granulari (inserita in Fase 1, +1 settimana) **Obiettivo:** implementare il sistema di permessi a due livelli: ruoli globali + permessi per casella. **Task:** - [ ] Alembic migration per `mailbox_permissions` - [ ] `permission_service.py`: `check_user_can_read(user, mailbox)`, `check_user_can_send(user, mailbox)` - [ ] Middleware FastAPI: ogni endpoint messaggi/invio chiama `permission_service` prima di procedere - [ ] API `POST /permissions/mailboxes/{mailbox_id}/users/{user_id}` (admin assegna permesso) - [ ] API `DELETE /permissions/mailboxes/{mailbox_id}/users/{user_id}` (admin revoca) - [ ] API `GET /permissions/mailboxes/{mailbox_id}/users` (lista permessi casella) - [ ] API `GET /permissions/users/{user_id}/mailboxes` (caselle accessibili a un utente) - [ ] Test: utente senza permesso riceve 403, utente con `can_read=False` non vede messaggi - [ ] Frontend: pagina Permissions (admin) con tabella utenti × caselle con toggle **Definition of Done:** - Utente `readonly` senza permessi espliciti non vede nessuna casella - Admin aggiunge/revoca permesso e l'effetto è immediato (no cache JWT) - Test di integrazione coprono tutti i casi limite (ruolo vs permesso esplicito) --- ### Fase 5-A – Virtual Box (inserita dopo Fase 5, 2 settimane) **Obiettivo:** permettere agli admin di creare viste filtrate e assegnarle a utenti specifici. **Task:** - [ ] Alembic migration per `virtual_boxes`, `virtual_box_rules`, `virtual_box_assignments` - [ ] `virtual_box_service.py`: `build_filter_clauses(user_id)` → lista di clausole SQLAlchemy - [ ] Integrazione in `message_service.py`: se l'utente ha VBox assegnate, applica filtri - [ ] Stessa integrazione in `search_service.py` (ricerca non esce dal perimetro VBox) - [ ] API `POST /virtual-boxes` (crea VBox con regole) - [ ] API `PUT /virtual-boxes/{id}/rules` (aggiorna regole) - [ ] API `POST /virtual-boxes/{id}/assignments` (assegna utente) - [ ] API `DELETE /virtual-boxes/{id}/assignments/{user_id}` (rimuovi assegnazione) - [ ] Frontend: pagina VirtualBoxes – builder visuale delle regole (dropdown campi, input pattern) - [ ] Frontend: Inbox mostra badge "VBox attiva: [nome]" se l'utente è in modalità filtrata - [ ] Test: utente pbianchi in VBox "Multe info@" non vede mail con oggetto diverso da "Multa" **Definition of Done:** - Scenario di esempio funzionante: admin crea VBox, assegna utente, utente vede solo mail filtrate - Admin che non ha VBox assegnata vede tutto normalmente --- ### Fase 5-B – Ricerca Avanzata (inserita dopo Fase 5, 2 settimane) **Obiettivo:** ricerca full-text su tutti i campi del messaggio e sul contenuto degli allegati. **Task:** - [ ] Alembic migration: colonna `search_vector` GENERATED + `attachments.extracted_text` - [ ] `worker/search/text_extractor.py`: chiama Apache Tika (`docker run apache/tika`) via HTTP REST, estrae testo da PDF/DOCX/ODT/TXT/HTML - [ ] Job `index_message`: avviato dopo ogni sync IMAP, estrae testo allegati e aggiorna vettore - [ ] `search_service.py`: costruisce `tsquery` da input utente (parole AND, frasi con `""`, NOT con `-`) - [ ] API `POST /search`: accetta `{ q, mailbox_ids, direction, date_from, date_to, has_attachments, labels, pec_type }` - [ ] Risposta: `{ results: [...], total, highlights: { message_id: {field: snippet} } }` - [ ] Frontend: pagina Search con barra + pannello filtri laterale collapsible - [ ] Hook `useSearch`: debounce 400ms, history ultimi 10 termini (localStorage), highlight parole chiave nei risultati - [ ] Test: ricerca "Multa" trova messaggi con "Multa" in oggetto, corpo E allegato PDF contenente "Multa" **Definition of Done:** - Ricerca restituisce risultati in < 500ms su dataset di 100.000 messaggi - Evidenziazione (highlight) visibile nel frontend - I filtri VBox si applicano anche alla ricerca --- ### Fase 5-C – Sistema Notifiche (in parallelo con 5-A e 5-B, 3 settimane) **Obiettivo:** notifiche granulari su eventi PEC via webhook, email, Telegram e WhatsApp. **Task:** - [ ] Alembic migration per `notification_channels`, `notification_rules`, `notification_log` - [ ] API `POST /notifications/channels` (crea canale, config cifrata a riposo) - [ ] API `POST /notifications/channels/{id}/test` (invia notifica di test) - [ ] API `POST /notifications/rules` (crea regola evento → canale + filtri) - [ ] `notification_service.py`: al salvataggio di ogni messaggio, valuta regole applicabili e accoda job - [ ] `worker/notifications/dispatcher.py`: smista per tipo canale - [ ] `webhook.py`: POST JSON con header `X-PecFlow-Signature: sha256=` per verifica autenticità - [ ] `email_smtp.py`: template HTML notifica (oggetto, mittente, link messaggio) - [ ] `telegram.py`: messaggio Telegram con MarkdownV2, link deep al messaggio - [ ] `whatsapp.py`: Meta Cloud API `POST /messages` con template pre-approvato (o freeform in 24h window) - [ ] Retry: 3 tentativi per canale, log in `notification_log` - [ ] Frontend: pagina Notifications – gestione canali (form per tipo) + lista regole - [ ] Test integrazione: mock HTTP server per webhook, verifica HMAC; mock Telegram API **Definition of Done:** - Demo completa: nuovo messaggio PEC → webhook riceve POST in < 5 secondi - Telegram e WhatsApp testati con account sandbox - Log notifiche visibile in frontend (admin) --- ## 5. Sistemi Avanzati --- ### 5.1 Permessi Utente Granulari #### Gerarchia ruoli | Ruolo | Accesso caselle | Invio | Configurazione | Gestione utenti | |---|---|---|---|---| | `super_admin` | Tutti i tenant | ✓ | ✓ | ✓ | | `admin` | Tutte le caselle del tenant | ✓ | ✓ | ✓ | | `supervisor` | Caselle con permesso esplicito | ✓ | ✗ | ✗ | | `operator` | Caselle con `can_read=TRUE` | Solo se `can_send=TRUE` | ✗ | ✗ | | `readonly` | Caselle con `can_read=TRUE` | ✗ | ✗ | ✗ | #### Logica applicata nel backend ```python # permission_service.py def get_visible_mailboxes(user: User, db: Session) -> list[UUID]: """Restituisce le caselle visibili all'utente.""" if user.role in ('super_admin', 'admin'): return db.query(Mailbox.id).filter(Mailbox.tenant_id == user.tenant_id).all() return ( db.query(MailboxPermission.mailbox_id) .filter( MailboxPermission.user_id == user.id, MailboxPermission.can_read == True, ) .all() ) def check_can_send(user: User, mailbox_id: UUID, db: Session) -> bool: if user.role in ('super_admin', 'admin'): return True perm = db.query(MailboxPermission).filter_by( user_id=user.id, mailbox_id=mailbox_id ).first() return perm is not None and perm.can_send ``` #### Esempi di utilizzo reale - **Studio legale**: paralegale `plegal` può leggere e inviare da `pec@studiorossi.it`, non da `amministrazione@studiorossi.it` - **PA**: funzionario `fmarco` ha `can_read=TRUE`, `can_send=FALSE` → può leggere ma non rispondere - **Call center**: tutti gli operatori hanno `can_read` su `info@azienda.it`, solo il team leader ha `can_send` --- ### 5.2 Virtual Box #### Concetto Una **Virtual Box** è una vista nominata e filtrata su un sottoinsieme di messaggi. Quando un utente ha una o più VBox assegnate, l'applicazione applica automaticamente i filtri a *tutte* le sue query messaggi, inclusa la ricerca avanzata. Un utente può avere **più VBox** assegnate → vede l'**unione** (OR) dei messaggi che soddisfano almeno una VBox. Le regole *dentro* una singola VBox sono in **AND** tra loro. #### Esempio concreto **Scenario:** Comune di Esempio ha la casella `protocollo@comune.it`. Vuole che: - L'operatore `pbianchi` veda solo le PEC con oggetto contenente "Multa" da `protocollo@comune.it` - L'operatore `gverdi` veda solo le PEC da mittenti `@prefettura.it` ``` VBox "Multe - Protocollo" └── Regole: mailbox_id = subject_pattern = 'Multa' VBox "Prefettura - Protocollo" └── Regole: mailbox_id = from_pattern = 'prefettura.it' Assegnazioni: pbianchi → "Multe - Protocollo" gverdi → "Prefettura - Protocollo" ``` #### Implementazione SQL query ```sql -- Query messaggi per utente con VBox attiva -- Generata da virtual_box_service.py SELECT m.* FROM messages m WHERE m.tenant_id = :tenant_id AND m.mailbox_id IN ( -- solo caselle con permesso can_read SELECT mailbox_id FROM mailbox_permissions WHERE user_id = :user_id AND can_read = TRUE ) AND ( -- OR tra le VBox assegnate (m.mailbox_id = :vbox1_mailbox AND m.subject ILIKE '%Multa%') OR (m.mailbox_id = :vbox2_mailbox AND m.from_address ILIKE '%prefettura.it%') ) ORDER BY m.received_at DESC; ``` #### API Reference | Metodo | Endpoint | Descrizione | |---|---|---| | `POST` | `/virtual-boxes` | Crea VBox con regole iniziali | | `GET` | `/virtual-boxes` | Lista VBox del tenant | | `PUT` | `/virtual-boxes/{id}` | Aggiorna nome/descrizione | | `PUT` | `/virtual-boxes/{id}/rules` | Sostituisce tutte le regole | | `POST` | `/virtual-boxes/{id}/assignments` | Assegna utente | | `DELETE` | `/virtual-boxes/{id}/assignments/{user_id}` | Rimuovi assegnazione | | `GET` | `/virtual-boxes/my` | VBox dell'utente corrente | --- ### 5.3 Ricerca Avanzata #### Campi ricercabili | Campo | Tipo ricerca | Peso FTS | Note | |---|---|---|---| | `subject` | FTS + ILIKE | A (massimo) | | | `from_address` | FTS + ILIKE | B | | | `to_addresses` | FTS array | B | | | `body_text` | FTS | C | | | allegati `.extracted_text` | FTS | D (minimo) | dopo estrazione Tika | | `received_at` | range date | — | filtro, non FTS | | `state` | exact | — | filtro | | `direction` | exact | — | filtro | | `mailbox_id` | exact | — | filtro | | label | join | — | filtro | #### Sintassi query utente ``` multa comune → entrambe le parole (AND implicito) "avvenuta consegna" → frase esatta multa -cartella → multa ma NOT cartella comune OR prefettura → OR esplicito ``` Il `search_service.py` traduce la query utente in `tsquery` PostgreSQL: ```python # "multa comune" → to_tsquery('italian', 'multa & comune') # '"avvenuta consegna"' → phraseto_tsquery('italian', 'avvenuta consegna') ``` #### API ``` POST /search { "q": "multa comune", "mailbox_ids": ["uuid1", "uuid2"], // opzionale "direction": "inbound", // opzionale "date_from": "2026-01-01", // opzionale "date_to": "2026-12-31", // opzionale "has_attachments": true, // opzionale "labels": ["uuid_label"], // opzionale "pec_type": "posta_certificata", // opzionale "page": 1, "page_size": 25 } → 200 OK { "total": 142, "results": [ { "id": "...", "subject": "Multa n. 12345/2026 del Comune di Roma", "from_address": "protocollo@comune.roma.it", "received_at": "2026-03-15T10:30:00Z", "highlights": { "subject": "...del Comune di Roma", "body_text": "...in merito alla multa stradale..." } } ] } ``` #### Estrazione testo allegati Il worker `text_extractor.py` chiama Apache Tika REST API: ```python import httpx async def extract_text(file_bytes: bytes, content_type: str) -> str | None: async with httpx.AsyncClient() as client: r = await client.put( "http://tika:9998/tika", content=file_bytes, headers={"Content-Type": content_type, "Accept": "text/plain"}, timeout=30.0, ) return r.text if r.status_code == 200 else None ``` Apache Tika è incluso nel `docker-compose.yml` come container `tika:latest` (porta 9998). Supporta PDF, DOCX, ODT, XLSX, HTML, XML, TXT e altri 1000+ formati. --- ### 5.4 Sistema di Notifiche Multi-canale #### Architettura ``` Evento PEC (trigger: salvataggio nuovo messaggio o cambio stato) │ ├── notification_service.evaluate_rules(event_type, message) │ ├── filtra regole per event_type │ ├── applica filtri (mailbox, from_pattern, subject_pattern) │ └── per ogni regola corrispondente: │ → accoda job dispatch_notification(rule_id, message_id) │ └── [arq job queue Redis] │ └── dispatcher.py ├── carica canale + configurazione └── chiama channel handler specifico: ├── webhook.py ├── email_smtp.py ├── telegram.py └── whatsapp.py ``` #### Canali supportati **Webhook** ```json POST https://your-endpoint.com/pecflow-hook Headers: X-PecFlow-Event: message.received X-PecFlow-Signature: sha256= Content-Type: application/json Body: { "event": "message.received", "tenant_id": "...", "message": { "id": "...", "subject": "PEC ricevuta da comune.roma.it", "from_address": "protocollo@comune.roma.it", "mailbox": "info@azienda.it", "received_at": "2026-03-18T14:00:00Z", "state": "received", "url": "https://app.pecflow.it/messages/..." } } ``` **Email SMTP** Notifica in HTML via template configurabile. Il mittente usa un relay SMTP dedicato (non la casella PEC del cliente). Supporta più destinatari nella config del canale. **Telegram** ``` 🔔 Nuova PEC ricevuta 📮 Casella: info@azienda.it 👤 Da: protocollo@comune.roma.it 📋 Oggetto: Convocazione riunione del 20/03/2026 🕐 Ricevuta: 18/03/2026 14:00 🔗 Visualizza: https://app.pecflow.it/messages/... ``` **WhatsApp (Meta Cloud API)** Template pre-approvato Meta per notifiche al di fuori della finestra 24h. Per messaggi dentro la finestra 24h (es. risposta a notifica) si usa freeform. La configurazione del canale include `phone_number_id`, `access_token` (cifrato) e numero destinatario. #### Configurazione canale via API ```json POST /notifications/channels { "name": "Webhook ERP Aziendale", "type": "webhook", "config": { "url": "https://erp.azienda.it/api/pec-webhook", "secret": "mysecretkey123" } } POST /notifications/channels { "name": "Alert Telegram Team", "type": "telegram", "config": { "bot_token": "123456:ABC...", "chat_id": "-1001234567890" } } POST /notifications/rules { "channel_id": "uuid-del-canale", "event_type": "message.received", "mailbox_id": "uuid-casella-info", // opzionale "from_pattern": "comune.roma.it" // opzionale } ``` #### Test canale ``` POST /notifications/channels/{id}/test → invia una notifica di test con dati fittizi al canale → risponde con: { "status": "sent" | "failed", "error": "..." } ``` #### Gestione errori e retry | Scenario | Comportamento | |---|---| | Endpoint webhook irraggiungibile | Retry 3x (5 min → 30 min → 2h), poi `failed` in `notification_log` | | Telegram bot bloccato dall'utente | `failed` immediato, no retry, alert admin | | WhatsApp fuori finestra 24h senza template | Usa template approvato o salta con log warning | | Email SMTP rifiutata | Retry 3x, poi `failed` | | Tutti i canali di una regola falliti | Alert WebSocket all'admin del tenant |