Files
PecHub/ARCHITECTURE.md
T
2026-03-18 20:54:43 +01:00

80 KiB
Raw Blame History

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
  2. Modello Dati
  3. Piano di Sviluppo a Fasi
  4. Decisioni Architetturali
  5. 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.

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

-- ============================================================
-- ESTENSIONI
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";    -- gen_random_bytes per nonce AES


-- ============================================================
-- 1. TENANTS
--    Ogni organizzazione cliente del SaaS
-- ============================================================
CREATE TABLE tenants (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    slug            VARCHAR(63)  NOT NULL UNIQUE,   -- usato come subdomain: acme.pecflow.it
    name            VARCHAR(255) NOT NULL,
    plan            VARCHAR(50)  NOT NULL DEFAULT 'starter',  -- starter|pro|enterprise
    is_active       BOOLEAN      NOT NULL DEFAULT TRUE,
    max_mailboxes   INT          NOT NULL DEFAULT 5,
    max_users       INT          NOT NULL DEFAULT 10,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tenants_slug ON tenants (slug);


-- ============================================================
-- 2. USERS
-- ============================================================
CREATE TYPE user_role AS ENUM ('super_admin', 'admin', 'supervisor', 'operator', 'readonly');

CREATE TABLE users (
    id                  UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id           UUID         NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    email               VARCHAR(255) NOT NULL,
    password_hash       TEXT         NOT NULL,           -- bcrypt, work factor 12
    full_name           VARCHAR(255) NOT NULL,
    role                user_role    NOT NULL DEFAULT 'operator',
    is_active           BOOLEAN      NOT NULL DEFAULT TRUE,
    totp_secret         TEXT,                            -- cifrato con app key
    totp_enabled        BOOLEAN      NOT NULL DEFAULT FALSE,
    last_login_at       TIMESTAMPTZ,
    failed_login_count  INT          NOT NULL DEFAULT 0,
    locked_until        TIMESTAMPTZ,
    created_at          TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ  NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_user_email_tenant UNIQUE (tenant_id, email)
);

CREATE INDEX idx_users_tenant    ON users (tenant_id);
CREATE INDEX idx_users_email     ON users (email);

-- RLS: un utente vede solo gli utenti del proprio tenant
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY users_tenant_isolation ON users
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);


-- ============================================================
-- 3. REFRESH TOKENS
-- ============================================================
CREATE TABLE refresh_tokens (
    id          UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id     UUID        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token_hash  TEXT        NOT NULL UNIQUE,   -- SHA-256 del token raw
    issued_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at  TIMESTAMPTZ NOT NULL,
    revoked_at  TIMESTAMPTZ,
    user_agent  TEXT,
    ip_address  INET
);

CREATE INDEX idx_rt_user    ON refresh_tokens (user_id);
CREATE INDEX idx_rt_expires ON refresh_tokens (expires_at) WHERE revoked_at IS NULL;


-- ============================================================
-- 4. MAILBOXES  (caselle PEC)
-- ============================================================
CREATE TYPE mailbox_status AS ENUM ('active', 'paused', 'error', 'deleted');

CREATE TABLE mailboxes (
    id                  UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id           UUID         NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    email_address       VARCHAR(255) NOT NULL,
    display_name        VARCHAR(255),
    provider            VARCHAR(100),                 -- 'aruba', 'namirial', 'legalmail', ecc.

    -- credenziali IMAP/SMTP cifrate (AES-256-GCM)
    -- formato colonna: base64(nonce || ciphertext || tag)
    imap_host_enc       TEXT         NOT NULL,
    imap_port_enc       TEXT         NOT NULL,
    imap_user_enc       TEXT         NOT NULL,
    imap_pass_enc       TEXT         NOT NULL,
    imap_use_ssl        BOOLEAN      NOT NULL DEFAULT TRUE,

    smtp_host_enc       TEXT         NOT NULL,
    smtp_port_enc       TEXT         NOT NULL,
    smtp_user_enc       TEXT         NOT NULL,
    smtp_pass_enc       TEXT         NOT NULL,
    smtp_use_tls        BOOLEAN      NOT NULL DEFAULT TRUE,

    status              mailbox_status NOT NULL DEFAULT 'active',
    last_sync_at        TIMESTAMPTZ,
    last_sync_uid       BIGINT,                       -- ultimo UID IMAP sincronizzato
    sync_error_msg      TEXT,
    sync_error_count    INT          NOT NULL DEFAULT 0,

    created_by          UUID         REFERENCES users(id),
    created_at          TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ  NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_mailbox_email_tenant UNIQUE (tenant_id, email_address)
);

CREATE INDEX idx_mailboxes_tenant ON mailboxes (tenant_id);
CREATE INDEX idx_mailboxes_status ON mailboxes (status) WHERE status = 'active';

ALTER TABLE mailboxes ENABLE ROW LEVEL SECURITY;
CREATE POLICY mailboxes_tenant_isolation ON mailboxes
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);


-- ============================================================
-- 5. MESSAGES  (PEC ricevute e inviate)
-- ============================================================
CREATE TYPE pec_direction  AS ENUM ('inbound', 'outbound');
CREATE TYPE pec_state      AS ENUM (
    -- outbound flow
    'draft', 'queued', 'sent', 'accepted', 'delivered', 'anomaly', 'failed',
    -- inbound
    'received'
);
CREATE TYPE pec_msg_type   AS ENUM (
    'posta_certificata',     -- messaggio originale PEC
    'accettazione',          -- ricevuta di accettazione dal gestore mittente
    'non_accettazione',      -- ricevuta di non accettazione
    'presa_in_carico',       -- ricevuta intermedia alcuni provider
    'avvenuta_consegna',     -- ricevuta di avvenuta consegna dal gestore destinatario
    'mancata_consegna',      -- ricevuta di mancata consegna (timeout / errore)
    'errore_consegna',       -- ricevuta di errore consegna
    'preavviso_mancata_consegna',
    'rilevazione_virus',
    'unknown'
);

CREATE TABLE messages (
    id                  UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id           UUID          NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    mailbox_id          UUID          NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,

    -- identificatori
    message_id_header   TEXT,                          -- valore header Message-ID
    imap_uid            BIGINT,                        -- UID IMAP nella casella
    imap_folder         VARCHAR(255)  NOT NULL DEFAULT 'INBOX',

    direction           pec_direction NOT NULL,
    pec_type            pec_msg_type  NOT NULL DEFAULT 'posta_certificata',
    state               pec_state     NOT NULL,

    -- busta PEC
    subject             TEXT,
    from_address        VARCHAR(255),
    to_addresses        TEXT[],                        -- array destinatari
    cc_addresses        TEXT[],
    sent_at             TIMESTAMPTZ,                   -- data invio originale
    received_at         TIMESTAMPTZ,                   -- data ricezione IMAP
    size_bytes          BIGINT,

    -- corpo
    body_text           TEXT,                          -- testo plain estratto
    body_html           TEXT,                          -- HTML estratto (solo per visualizzazione)
    has_attachments     BOOLEAN       NOT NULL DEFAULT FALSE,

    -- per messaggi outbound: riferimento alla PEC originale di cui sono ricevuta
    parent_message_id   UUID          REFERENCES messages(id),

    -- flag operativi
    is_read             BOOLEAN       NOT NULL DEFAULT FALSE,
    is_starred          BOOLEAN       NOT NULL DEFAULT FALSE,
    is_archived         BOOLEAN       NOT NULL DEFAULT FALSE,
    archived_at         TIMESTAMPTZ,

    -- raw storage path (allegati e raw EML)
    raw_eml_path        TEXT,                          -- percorso in object storage

    created_at          TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ   NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_messages_tenant       ON messages (tenant_id);
CREATE INDEX idx_messages_mailbox      ON messages (mailbox_id);
CREATE INDEX idx_messages_state        ON messages (state);
CREATE INDEX idx_messages_received_at  ON messages (received_at DESC);
CREATE INDEX idx_messages_parent       ON messages (parent_message_id) WHERE parent_message_id IS NOT NULL;
CREATE INDEX idx_messages_imap_uid     ON messages (mailbox_id, imap_uid);
-- Full-text search sul soggetto
CREATE INDEX idx_messages_subject_fts  ON messages USING GIN (to_tsvector('italian', COALESCE(subject, '')));

ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY messages_tenant_isolation ON messages
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);


-- ============================================================
-- 6. ATTACHMENTS
-- ============================================================
CREATE TABLE attachments (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id       UUID         NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    message_id      UUID         NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    filename        VARCHAR(512) NOT NULL,
    content_type    VARCHAR(255),
    size_bytes      BIGINT,
    storage_path    TEXT         NOT NULL,   -- percorso MinIO/S3: tenants/{tid}/msgs/{mid}/{filename}
    checksum_sha256 CHAR(64),               -- integrità
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_attachments_message ON attachments (message_id);


-- ============================================================
-- 7. SEND_JOBS  (tracciamento invii SMTP)
-- ============================================================
CREATE TYPE send_job_status AS ENUM ('pending', 'sending', 'sent', 'failed', 'retrying');

CREATE TABLE send_jobs (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id       UUID              NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    mailbox_id      UUID              NOT NULL REFERENCES mailboxes(id),
    message_id      UUID              REFERENCES messages(id),  -- NULL fino all'invio riuscito
    status          send_job_status   NOT NULL DEFAULT 'pending',
    attempt_count   INT               NOT NULL DEFAULT 0,
    max_attempts    INT               NOT NULL DEFAULT 5,
    next_retry_at   TIMESTAMPTZ,
    last_error      TEXT,
    queued_at       TIMESTAMPTZ       NOT NULL DEFAULT NOW(),
    sent_at         TIMESTAMPTZ,
    created_by      UUID              REFERENCES users(id)
);

CREATE INDEX idx_sendjobs_tenant  ON send_jobs (tenant_id);
CREATE INDEX idx_sendjobs_status  ON send_jobs (status, next_retry_at) WHERE status IN ('pending','retrying');


-- ============================================================
-- 8. ARCHIVAL_BATCHES  (versamenti verso conservatore AgID)
-- ============================================================
CREATE TYPE archival_status AS ENUM (
    'pending',          -- in coda
    'building_sip',     -- generazione pacchetto SIP
    'uploading',        -- trasferimento al conservatore
    'uploaded',         -- ricevuta di versamento ricevuta (RdV)
    'confirmed',        -- conservatore ha confermato presa in carico
    'rejected',         -- conservatore ha rifiutato il versamento
    'failed'            -- errore tecnico non recuperabile
);

CREATE TABLE archival_batches (
    id                  UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id           UUID             NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    conservatore_id     VARCHAR(100)     NOT NULL,   -- codice conservatore (es. 'aruba-cons', 'docuvision')
    status              archival_status  NOT NULL DEFAULT 'pending',
    
    sip_path            TEXT,                        -- path del pacchetto SIP generato
    sip_checksum        CHAR(64),                    -- SHA-256 del SIP
    
    -- risposta dal conservatore
    versamento_id       TEXT,                        -- ID assegnato dal conservatore
    rdv_received_at     TIMESTAMPTZ,
    rdv_path            TEXT,                        -- path della RdV archiviata
    rdv_checksum        CHAR(64),
    
    attempt_count       INT              NOT NULL DEFAULT 0,
    max_attempts        INT              NOT NULL DEFAULT 3,
    next_retry_at       TIMESTAMPTZ,
    last_error          TEXT,
    
    period_from         DATE             NOT NULL,   -- periodo di riferimento del versamento
    period_to           DATE             NOT NULL,
    
    created_at          TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ      NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_archival_tenant  ON archival_batches (tenant_id);
CREATE INDEX idx_archival_status  ON archival_batches (status, next_retry_at);

-- Tabella pivot: quali messaggi sono inclusi in quale batch
CREATE TABLE archival_batch_messages (
    batch_id    UUID NOT NULL REFERENCES archival_batches(id) ON DELETE CASCADE,
    message_id  UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    PRIMARY KEY (batch_id, message_id)
);

-- DIP (Dissemination Information Package): recupero da conservatore
CREATE TABLE archival_dips (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id       UUID         NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    batch_id        UUID         REFERENCES archival_batches(id),
    requested_by    UUID         REFERENCES users(id),
    dip_path        TEXT,
    status          VARCHAR(50)  NOT NULL DEFAULT 'requested',
    requested_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    received_at     TIMESTAMPTZ
);


-- ============================================================
-- 9. AUDIT_LOG
--    Immutabile (no UPDATE/DELETE via applicazione)
-- ============================================================
CREATE TABLE audit_log (
    id              BIGSERIAL    PRIMARY KEY,
    tenant_id       UUID         REFERENCES tenants(id),
    user_id         UUID         REFERENCES users(id),
    action          VARCHAR(100) NOT NULL,   -- 'message.read', 'mailbox.create', ecc.
    resource_type   VARCHAR(100),
    resource_id     UUID,
    ip_address      INET,
    user_agent      TEXT,
    payload         JSONB,                   -- parametri dell'azione (sanitizzati)
    outcome         VARCHAR(20)  NOT NULL DEFAULT 'success',  -- success|failure
    occurred_at     TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

-- Partizionamento per range (mese) per gestire volumi grandi senza purge
-- In Postgres 14+ si può fare: PARTITION BY RANGE (occurred_at)
-- Per semplicità in v1, si aggiunge un indice composito
CREATE INDEX idx_audit_tenant_date ON audit_log (tenant_id, occurred_at DESC);
CREATE INDEX idx_audit_user        ON audit_log (user_id, occurred_at DESC);
CREATE INDEX idx_audit_action      ON audit_log (action);

-- Blocca UPDATE/DELETE anche per DBA (si può bypassare solo con superuser esplicito)
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY audit_no_delete ON audit_log FOR DELETE USING (FALSE);
CREATE POLICY audit_no_update ON audit_log FOR UPDATE USING (FALSE);
CREATE POLICY audit_tenant_read ON audit_log FOR SELECT
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);


-- ============================================================
-- 10. LABELS / ETICHETTE  (tagging messaggi)
-- ============================================================
CREATE TABLE labels (
    id          UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id   UUID         NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    name        VARCHAR(100) NOT NULL,
    color       CHAR(7),   -- hex color #RRGGBB
    CONSTRAINT uq_label_name_tenant UNIQUE (tenant_id, name)
);

CREATE TABLE message_labels (
    message_id  UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    label_id    UUID NOT NULL REFERENCES labels(id)   ON DELETE CASCADE,
    PRIMARY KEY (message_id, label_id)
);


-- ============================================================
-- TRIGGER: updated_at automatico
-- ============================================================
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$;

DO $$ DECLARE t TEXT; BEGIN
    FOREACH t IN ARRAY ARRAY['tenants','users','mailboxes','messages','archival_batches'] LOOP
        EXECUTE format('CREATE TRIGGER trg_%s_updated_at
            BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION set_updated_at()', t, t);
    END LOOP;
END $$;

3. Piano di Sviluppo a Fasi

Fase 1 Fondamenta (3 settimane)

Obiettivo: infrastruttura funzionante, autenticazione sicura, deploy locale.

Task:

  • Inizializzare monorepo con struttura cartelle, .gitignore, Makefile
  • docker-compose.yml con PostgreSQL 16, Redis 7, MinIO (S3 local), Nginx
  • Backend FastAPI: struttura app, config pydantic-settings, health endpoint
  • Alembic: prima migrazione con tutte le tabelle DDL (sezione 2)
  • Modello tenants + users + ORM SQLAlchemy corrispondente
  • Auth API: POST /auth/login (email+password), JWT access token (15 min) + refresh token (30 gg)
  • Middleware RLS: set_config('app.current_tenant_id', ...) su ogni request
  • API 2FA TOTP: POST /auth/totp/setup, POST /auth/totp/verify
  • Rate limiting su endpoint auth (slowapi)
  • CRUD utenti base (admin crea/disabilita utenti dello stesso tenant)
  • CI GitHub Actions: lint (ruff, mypy), test (pytest), build Docker
  • Seed script: tenant demo + utente admin per dev locale

Definition of Done:

  • Login, refresh token e 2FA funzionanti e coperti da test di integrazione
  • make dev porta in piedi tutto lo stack locale
  • CI verde su PR

Fase 2 IMAP Sync Engine (4 settimane)

Obiettivo: connessione IMAP stabile a N caselle in parallelo, sincronizzazione messaggi.

Task:

  • CRUD caselle PEC (POST /mailboxes, cifratura credenziali AES-256-GCM)
  • Worker arq: entrypoint, connessione Redis, worker health check
  • MailboxPool: avvia un task async per ogni casella status=active all'avvio
  • IMAPConnection: connessione IMAP via aioimaplib, gestione IDLE con heartbeat 28 min (RFC 2177)
  • Polling fallback: se IDLE non supportato, polling ogni 60 secondi
  • Backoff esponenziale su disconnessione (1s → 2s → 4s → max 5 min)
  • Fetch UID list + envelope per messaggi nuovi (UID > last_sync_uid)
  • Salvataggio messages con campi base, aggiornamento last_sync_uid
  • Download body e raw EML su MinIO: tenants/{tid}/raw/{mailbox_id}/{uid}.eml
  • Aggiornamento stato casella: sync_error_count, status=error dopo N fallimenti
  • Notifica WebSocket su nuovo messaggio: evento mailbox:new_message al tenant
  • Test di integrazione con Greenmail (server IMAP mock Java) via Docker

Definition of Done:

  • Connessione a casella Aruba PEC reale in ambiente di test (sandbox)
  • Verifica leggibilità mail
  • Riconnessione automatica verificata (kill connessione di rete e attesa recovery)

Fase 3 Parser PEC & Tracking Ricevute (3 settimane)

Obiettivo: classificare correttamente i messaggi PEC e collegare le ricevute.

Task:

  • pec_parser.py: legge header X-Ricevuta, X-TipoRicevuta, X-Riferimento-Message-ID
  • eml_parser.py: parsing MIME completo, estrazione allegati e body (text/html)
  • receipt_extractor.py: estrae EML allegato dentro ricevuta (EML-in-EML annidato)
  • Mappatura pec_msg_type da header PEC a enum DB
  • Collegamento parent_message_id: associa ricevuta al messaggio originale via X-Riferimento-Message-ID
  • State machine messaggi outbound: sent→accepted→delivered (o anomaly)
  • Download e salvataggio allegati su MinIO, inserimento tabella attachments
  • Test unitari parser con EML reali (fixture anonimizzate) per tutti i tipi di ricevuta
  • Test regressione: parsing Aruba / Namirial / Legalmail (formato header leggermente diverso per provider)

Definition of Done:

  • 100% dei tipi ricevuta classificati correttamente su un set di 50+ EML reali
  • I messaggi outbound aggiornano stato automaticamente all'arrivo della ricevuta

Fase 4 Invio SMTP (2 settimane)

Obiettivo: invio PEC affidabile con retry e tracking.

Task:

  • API POST /send: validazione, creazione send_job e message in stato draftqueued
  • 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:

  • 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 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.


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_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

# 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

-- 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/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

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