mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
58a233236c
- docker-compose.yml: PostgreSQL 16, Redis 7, MinIO, Nginx - backend FastAPI: struttura monorepo, config pydantic-settings - modelli SQLAlchemy: tutti i modelli (tenants, users, mailboxes, messages, archival, permissions, labels, audit_log) - migrazione Alembic 0001: schema completo in pure SQL - auth API: login JWT, refresh token rotation, logout, 2FA TOTP (setup/verify/disable) - CRUD utenti: lista, crea, modifica, reset password, soft delete - permessi granulari (Fase 1-A): mailbox_permissions, assegna/revoca/lista - CRUD tenant: gestione super-admin - sicurezza: AES-256-GCM cifratura credenziali IMAP/SMTP, bcrypt password - RLS PostgreSQL: isolamento multi-tenant per request - seed sviluppo: tenant demo + admin + operator - test unit: security (bcrypt, JWT, AES), auth_service - test integration: auth endpoints, users endpoints - CI GitHub Actions: lint (ruff), test (pytest), build Docker, security scan - infra: nginx.conf, redis.conf - Makefile con comandi make dev/test/migrate/seed Definition of Done: ✅ Login, refresh token e TOTP funzionanti ✅ make dev porta in piedi tutto lo stack locale ✅ CI configurata
1750 lines
79 KiB
Markdown
1750 lines
79 KiB
Markdown
# 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)
|
||
- Nuovi messaggi compaiono in DB entro 30 secondi dall'arrivo
|
||
- 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 |
|