78 KiB
PEChub – 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
- Struttura Repository
- Modello Dati
- Piano di Sviluppo a Fasi
- Decisioni Architetturali
- Sistemi Avanzati
1. Struttura Repository
Monorepo con workspace separati. Il confine di responsabilità è netto: ogni cartella di primo livello è un deployable (o una libreria condivisa) indipendente.
PEChub/ ← 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/
│ │ └── pechub.conf
│ ├── redis/
│ │ └── redis.conf # maxmemory, eviction policy
│ ├── prometheus/
│ │ └── prometheus.yml
│ └── grafana/
│ └── dashboards/
│ └── pechub.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
-- ============================================================
-- 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.pechub.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.ymlcon 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 devporta 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 casellastatus=activeall'avvioIMAPConnection: 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
messagescon campi base, aggiornamentolast_sync_uid - Download body e raw EML su MinIO:
tenants/{tid}/raw/{mailbox_id}/{uid}.eml - Aggiornamento stato casella:
sync_error_count,status=errordopo N fallimenti - Notifica WebSocket su nuovo messaggio: evento
mailbox:new_messageal 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-IDeml_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_typeda header PEC a enum DB - Collegamento
parent_message_id: associa ricevuta al messaggio originale viaX-Riferimento-Message-ID - State machine messaggi outbound:
sent→accepted→delivered(oanomaly) - 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, creazionesend_jobemessagein statodraft→queued - Job
send_pec: connessione SMTP STARTTLS/SSL via aiosmtplib - Gestione
To,Ccmultipli, 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:
- ✅ API POST /send, GET /send/jobs, DELETE /send/jobs/{id} implementate e testate (13/13 test passati)
- ✅ Job send_pec con retry esponenziale e watch_receipt registrati nel worker
- ✅ SmtpSender con supporto SSL/STARTTLS (porta 465/587) e costruzione MIME corretta
- ✅ Upload raw EML su MinIO (percorso outbound/{message_id}.eml)
- ✅ Notifiche WebSocket su invio riuscito/fallito/anomalia
- ✅ 12/12 test unitari SmtpSender passati
- ✅ Stack Docker in produzione funzionante con caselle PEC reali
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(WebSocket nativo): aggiorna inbox in real-time con backoff esponenziale - Inbox: lista messaggi paginata, filtri (casella, direzione, stato, testo), badge PEC state
- Dettaglio messaggio: corpo (HTML/testo), allegati scaricabili, tree ricevute (
ReceiptTree) - Composizione PEC: form To/Cc multipli/Subject/Body, risposta a thread, invio
- Gestione Caselle (admin): CRUD, test connessione IMAP/SMTP, stato sync
- Gestione Utenti (admin): CRUD, cambio ruolo, reset password, toggle attivo
- Gestione Permessi: matrice utenti × caselle con toggle can_read/can_send/can_manage
- Internazionalizzazione: tutta l'UI in italiano (nessuna stringa in inglese)
- Zustand stores: auth, inbox, mailbox
- React Query: caching, refetch automatico, invalidation
- Dockerfile multi-stage (dev: Vite hot-reload, prod: nginx)
Definition of Done:
- ✅ Build TypeScript 0 errori, bundle 467KB (146KB gzip)
- ✅ Stack Docker completo funzionante (HTTP 200, tutti i moduli caricati)
- ✅ Frontend accessibile su http://localhost
- ✅ Backend health confermato via Nginx proxy
- ✅ Tutti i container Up: db, redis, minio, backend, frontend, worker, nginx
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 inarchival_batches- Job
archive_batch: seleziona messaggiarchived=falsenel 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-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:
- Backoff esponenziale con jitter: tentativo 1 subito, poi 1h, 4h, 24h (max 3 retry oltre il primo)
- Dead letter queue: dopo tutti i retry falliti, il batch va in stato
failede genera un alert email all'admin del tenant + al team ops PEChub - Idempotenza: prima di ogni retry, verificare se il conservatore ha già ricevuto il versamento (
GET /versamento/{id}) per evitare duplicati - 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
- 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
-- ============================================================
-- 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_serviceprima 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=Falsenon vede messaggi - Frontend: pagina Permissions (admin) con tabella utenti × caselle con toggle
Definition of Done:
- Utente
readonlysenza 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_vectorGENERATED +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: costruiscetsqueryda 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 jobworker/notifications/dispatcher.py: smista per tipo canalewebhook.py: POST JSON con headerX-PEChub-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 messaggiowhatsapp.py: Meta Cloud APIPOST /messagescon 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
# 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
plegalpuò leggere e inviare dapec@studiorossi.it, non daamministrazione@studiorossi.it - PA: funzionario
fmarcohacan_read=TRUE,can_send=FALSE→ può leggere ma non rispondere - Call center: tutti gli operatori hanno
can_readsuinfo@azienda.it, solo il team leader hacan_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
pbianchiveda solo le PEC con oggetto contenente "Multa" daprotocollo@comune.it - L'operatore
gverdiveda 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
-- 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:
# "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:
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
POST https://your-endpoint.com/pechub-hook
Headers:
X-PEChub-Event: message.received
X-PEChub-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.pechub.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.pechub.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
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 |