Files
PecHub/ARCHITECTURE.md
T
2026-03-18 17:30:13 +01:00

1750 lines
79 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 = <info@>
└── 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=<HMAC>` 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 = <protocollo@comune.it>
subject_pattern = 'Multa'
VBox "Prefettura - Protocollo"
└── Regole:
mailbox_id = <protocollo@comune.it>
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 <mark>Comune</mark> di Roma",
"body_text": "...in merito alla <mark>multa</mark> 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=<HMAC-SHA256(secret, body)>
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 |