From 58a233236c8b1a2ace3c70be3c9f5f9365de8d59 Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Wed, 18 Mar 2026 16:42:01 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Fase=201=20=E2=80=93=20Fondamenta=20com?= =?UTF-8?q?plete=20(backend=20FastAPI=20+=20auth=20+=20permessi)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: PostgreSQL 16, Redis 7, MinIO, Nginx - backend FastAPI: struttura monorepo, config pydantic-settings - modelli SQLAlchemy: tutti i modelli (tenants, users, mailboxes, messages, archival, permissions, labels, audit_log) - migrazione Alembic 0001: schema completo in pure SQL - auth API: login JWT, refresh token rotation, logout, 2FA TOTP (setup/verify/disable) - CRUD utenti: lista, crea, modifica, reset password, soft delete - permessi granulari (Fase 1-A): mailbox_permissions, assegna/revoca/lista - CRUD tenant: gestione super-admin - sicurezza: AES-256-GCM cifratura credenziali IMAP/SMTP, bcrypt password - RLS PostgreSQL: isolamento multi-tenant per request - seed sviluppo: tenant demo + admin + operator - test unit: security (bcrypt, JWT, AES), auth_service - test integration: auth endpoints, users endpoints - CI GitHub Actions: lint (ruff), test (pytest), build Docker, security scan - infra: nginx.conf, redis.conf - Makefile con comandi make dev/test/migrate/seed Definition of Done: ✅ Login, refresh token e TOTP funzionanti ✅ make dev porta in piedi tutto lo stack locale ✅ CI configurata --- .env.example | 61 + .github/workflows/ci.yml | 188 ++ ARCHITECTURE.md | 1749 +++++++++++++++++ KnowledgeBaseCline.md | 43 + Makefile | 106 + backend/Dockerfile | 26 + backend/alembic.ini | 55 + backend/alembic/__init__.py | 1 + backend/alembic/env.py | 76 + .../alembic/versions/0001_initial_schema.py | 358 ++++ backend/app/__init__.py | 1 + backend/app/api/__init__.py | 1 + backend/app/api/v1/__init__.py | 1 + backend/app/api/v1/auth.py | 173 ++ backend/app/api/v1/permissions.py | 112 ++ backend/app/api/v1/tenants.py | 80 + backend/app/api/v1/users.py | 147 ++ backend/app/config.py | 90 + backend/app/core/__init__.py | 1 + backend/app/core/exceptions.py | 123 ++ backend/app/core/logging.py | 65 + backend/app/core/pagination.py | 56 + backend/app/core/security.py | 158 ++ backend/app/database.py | 52 + backend/app/dependencies.py | 117 ++ backend/app/main.py | 108 + backend/app/models/__init__.py | 9 + backend/app/models/archival.py | 108 + backend/app/models/audit_log.py | 51 + backend/app/models/label.py | 46 + backend/app/models/mailbox.py | 94 + backend/app/models/message.py | 191 ++ backend/app/models/permission.py | 71 + backend/app/models/tenant.py | 47 + backend/app/models/user.py | 129 ++ backend/app/schemas/__init__.py | 1 + backend/app/schemas/auth.py | 68 + backend/app/schemas/permission.py | 50 + backend/app/schemas/tenant.py | 54 + backend/app/schemas/user.py | 89 + backend/app/services/__init__.py | 1 + backend/app/services/auth_service.py | 302 +++ backend/app/services/permission_service.py | 236 +++ backend/app/services/tenant_service.py | 81 + backend/app/services/user_service.py | 145 ++ backend/pyproject.toml | 115 ++ backend/tests/__init__.py | 1 + backend/tests/integration/__init__.py | 1 + backend/tests/integration/conftest.py | 144 ++ backend/tests/integration/test_api_auth.py | 142 ++ backend/tests/integration/test_api_users.py | 131 ++ backend/tests/unit/__init__.py | 1 + backend/tests/unit/test_auth_service.py | 149 ++ backend/tests/unit/test_security.py | 143 ++ database/init/00_extensions.sql | 8 + database/seeds/dev_tenant.sql | 80 + docker-compose.yml | 153 ++ infra/nginx/conf.d/pecflow.conf | 72 + infra/nginx/nginx.conf | 45 + infra/redis/redis.conf | 36 + 60 files changed, 6942 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 ARCHITECTURE.md create mode 100644 KnowledgeBaseCline.md create mode 100644 Makefile create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/__init__.py create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/versions/0001_initial_schema.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/permissions.py create mode 100644 backend/app/api/v1/tenants.py create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/config.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/exceptions.py create mode 100644 backend/app/core/logging.py create mode 100644 backend/app/core/pagination.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/database.py create mode 100644 backend/app/dependencies.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/archival.py create mode 100644 backend/app/models/audit_log.py create mode 100644 backend/app/models/label.py create mode 100644 backend/app/models/mailbox.py create mode 100644 backend/app/models/message.py create mode 100644 backend/app/models/permission.py create mode 100644 backend/app/models/tenant.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/permission.py create mode 100644 backend/app/schemas/tenant.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/app/services/permission_service.py create mode 100644 backend/app/services/tenant_service.py create mode 100644 backend/app/services/user_service.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/conftest.py create mode 100644 backend/tests/integration/test_api_auth.py create mode 100644 backend/tests/integration/test_api_users.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_auth_service.py create mode 100644 backend/tests/unit/test_security.py create mode 100644 database/init/00_extensions.sql create mode 100644 database/seeds/dev_tenant.sql create mode 100644 docker-compose.yml create mode 100644 infra/nginx/conf.d/pecflow.conf create mode 100644 infra/nginx/nginx.conf create mode 100644 infra/redis/redis.conf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bf04a59 --- /dev/null +++ b/.env.example @@ -0,0 +1,61 @@ +# ───────────────────────────────────────────────────────────────────────────── +# PecFlow – Variabili d'ambiente +# Copia questo file in .env e personalizza i valori +# NON committare mai il file .env con valori reali +# ───────────────────────────────────────────────────────────────────────────── + +# ── Applicazione ───────────────────────────────────────────────────────────── +APP_ENV=development # development | staging | production +APP_DEBUG=true +APP_HOST=0.0.0.0 +APP_PORT=8000 +APP_BASE_URL=http://localhost:8000 + +# ── Sicurezza ───────────────────────────────────────────────────────────────── +# Genera con: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=change-me-generate-a-random-64-char-hex-string-here-00000000000000 +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=30 + +# Chiave AES-256-GCM per cifratura credenziali IMAP/SMTP (32 bytes = 64 hex chars) +# Genera con: python -c "import secrets; print(secrets.token_hex(32))" +ENCRYPTION_KEY=change-me-generate-a-random-64-char-hex-string-here-11111111111 + +# ── Database PostgreSQL ─────────────────────────────────────────────────────── +POSTGRES_HOST=db +POSTGRES_PORT=5432 +POSTGRES_DB=pecflow +POSTGRES_USER=pecflow +POSTGRES_PASSWORD=pecflow_dev_password + +DATABASE_URL=postgresql+asyncpg://pecflow:pecflow_dev_password@db:5432/pecflow +DATABASE_URL_SYNC=postgresql://pecflow:pecflow_dev_password@db:5432/pecflow + +# ── Redis ───────────────────────────────────────────────────────────────────── +REDIS_URL=redis://redis:6379/0 + +# ── MinIO (Object Storage) ──────────────────────────────────────────────────── +MINIO_ENDPOINT=minio:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=pecflow +MINIO_USE_SSL=false + +# ── CORS ────────────────────────────────────────────────────────────────────── +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# ── Rate Limiting ───────────────────────────────────────────────────────────── +RATE_LIMIT_AUTH=10/minute # max 10 tentativi di login al minuto per IP +RATE_LIMIT_DEFAULT=100/minute + +# ── Logging ─────────────────────────────────────────────────────────────────── +LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR | CRITICAL +LOG_JSON=false # true in produzione per log strutturati JSON + +# ── Email SMTP (per notifiche di sistema, NON caselle PEC) ─────────────────── +SYSTEM_SMTP_HOST= +SYSTEM_SMTP_PORT=587 +SYSTEM_SMTP_USER= +SYSTEM_SMTP_PASSWORD= +SYSTEM_SMTP_FROM=noreply@pecflow.it diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..920ad31 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,188 @@ +name: CI – Lint, Test, Build + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + PYTHON_VERSION: "3.12" + ENCRYPTION_KEY: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + SECRET_KEY: "ci-test-secret-key-for-github-actions-only-not-for-production" + DATABASE_URL: "postgresql+asyncpg://pecflow:pecflow_ci@localhost:5432/pecflow_test" + DATABASE_URL_SYNC: "postgresql://pecflow:pecflow_ci@localhost:5432/pecflow_test" + REDIS_URL: "redis://localhost:6379/0" + +jobs: + # ── Lint Backend ───────────────────────────────────────────────────────────── + lint-backend: + name: Lint Backend (ruff + mypy) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Install dependencies + working-directory: backend + run: | + pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run ruff (lint) + working-directory: backend + run: ruff check app tests --output-format=github + + - name: Run ruff (format check) + working-directory: backend + run: ruff format --check app tests + + - name: Run mypy (type check) + working-directory: backend + env: + ENCRYPTION_KEY: ${{ env.ENCRYPTION_KEY }} + SECRET_KEY: ${{ env.SECRET_KEY }} + DATABASE_URL: ${{ env.DATABASE_URL }} + DATABASE_URL_SYNC: ${{ env.DATABASE_URL_SYNC }} + run: mypy app --ignore-missing-imports --no-strict-optional + + # ── Test Backend ───────────────────────────────────────────────────────────── + test-backend: + name: Test Backend (pytest) + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: pecflow_test + POSTGRES_USER: pecflow + POSTGRES_PASSWORD: pecflow_ci + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Install dependencies + working-directory: backend + run: | + pip install --upgrade pip + pip install -e ".[dev]" + pip install aiosqlite # per test integration con SQLite + + - name: Run unit tests + working-directory: backend + env: + ENCRYPTION_KEY: ${{ env.ENCRYPTION_KEY }} + SECRET_KEY: ${{ env.SECRET_KEY }} + DATABASE_URL: ${{ env.DATABASE_URL }} + DATABASE_URL_SYNC: ${{ env.DATABASE_URL_SYNC }} + run: pytest tests/unit -v --tb=short + + - name: Run integration tests + working-directory: backend + env: + ENCRYPTION_KEY: ${{ env.ENCRYPTION_KEY }} + SECRET_KEY: ${{ env.SECRET_KEY }} + DATABASE_URL: sqlite+aiosqlite:///./test_ci.db + DATABASE_URL_SYNC: sqlite:///./test_ci.db + run: pytest tests/integration -v --tb=short + + - name: Run all tests with coverage + working-directory: backend + env: + ENCRYPTION_KEY: ${{ env.ENCRYPTION_KEY }} + SECRET_KEY: ${{ env.SECRET_KEY }} + DATABASE_URL: sqlite+aiosqlite:///./test_ci_cov.db + DATABASE_URL_SYNC: sqlite:///./test_ci_cov.db + run: | + pytest tests/ \ + --cov=app \ + --cov-report=xml \ + --cov-report=term-missing \ + -v --tb=short + continue-on-error: true # non blocca la CI se coverage < target + + - name: Upload coverage to GitHub + uses: codecov/codecov-action@v4 + with: + file: backend/coverage.xml + flags: backend + continue-on-error: true + + # ── Build Docker ───────────────────────────────────────────────────────────── + build-docker: + name: Build Docker Image + runs-on: ubuntu-latest + needs: [lint-backend, test-backend] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build backend image + uses: docker/build-push-action@v5 + with: + context: ./backend + file: ./backend/Dockerfile + push: false + tags: pecflow-backend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ── Security Scan ───────────────────────────────────────────────────────────── + security: + name: Security Scan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install pip-audit + run: pip install pip-audit + + - name: Audit Python dependencies + working-directory: backend + run: pip-audit -r <(pip install -e ".[dev]" --dry-run 2>/dev/null || echo "") || true + continue-on-error: true + + - name: Scan for secrets (Gitleaks) + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..4425633 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1749 @@ +# PecFlow – Architettura di Sistema + +> **Documento redatto il:** 2026-03-18 | **Ultima revisione:** 2026-03-18 +> **Versione:** 2.0 +> **Stato:** Draft – da approvare prima di iniziare la Fase 1 + +--- + +## Indice + +1. [Struttura Repository](#1-struttura-repository) +2. [Modello Dati](#2-modello-dati) +3. [Piano di Sviluppo a Fasi](#3-piano-di-sviluppo-a-fasi) +4. [Decisioni Architetturali](#4-decisioni-architetturali) +5. [Sistemi Avanzati](#5-sistemi-avanzati) + - 5.1 [Permessi Utente Granulari](#51-permessi-utente-granulari) + - 5.2 [Virtual Box](#52-virtual-box) + - 5.3 [Ricerca Avanzata](#53-ricerca-avanzata) + - 5.4 [Sistema di Notifiche Multi-canale](#54-sistema-di-notifiche-multi-canale) + +--- + +## 1. Struttura Repository + +Monorepo con workspace separati. Il confine di responsabilità è netto: ogni +cartella di primo livello è un deployable (o una libreria condivisa) indipendente. + +``` +PecFlow/ ← root del monorepo +│ +├── .github/ ← CI/CD GitHub Actions +│ ├── workflows/ +│ │ ├── ci.yml # lint + test su ogni PR +│ │ ├── cd-staging.yml # deploy automatico su staging +│ │ └── cd-production.yml # deploy manuale su prod (approval gate) +│ └── CODEOWNERS # chi deve approvare le PR per area +│ +├── .husky/ ← pre-commit hooks (lint, secret scan) +├── .env.example ← template variabili d'ambiente (NO valori reali) +├── .gitignore +├── .gitattributes +├── docker-compose.yml ← stack locale completo (dev) +├── docker-compose.prod.yml ← override per produzione +├── Makefile ← comandi rapidi: make dev, make test, make migrate +├── ARCHITECTURE.md ← questo file +├── CONTRIBUTING.md +│ +├── backend/ ═══════════════════════════════ +│ │ ← FastAPI – API HTTP + WebSocket +│ ├── Dockerfile +│ ├── pyproject.toml # dipendenze (uv / poetry) +│ ├── alembic.ini # configurazione Alembic migrations +│ │ +│ ├── alembic/ ← migrazioni DB versionate +│ │ ├── env.py +│ │ └── versions/ +│ │ └── 0001_initial_schema.py +│ │ +│ ├── app/ +│ │ ├── main.py # entrypoint FastAPI, registra router +│ │ ├── config.py # Settings (pydantic-settings, legge .env) +│ │ ├── database.py # engine SQLAlchemy, session factory +│ │ ├── dependencies.py # get_db, get_current_user, ecc. +│ │ │ +│ │ ├── api/ ← router HTTP (versioned) +│ │ │ └── v1/ +│ │ │ ├── auth.py # login, refresh, logout, 2FA +│ │ │ ├── tenants.py # CRUD tenant (super-admin) +│ │ │ ├── users.py # CRUD utenti +│ │ │ ├── mailboxes.py # CRUD caselle PEC +│ │ │ ├── messages.py # lista, dettaglio, search PEC +│ │ │ ├── search.py # ricerca avanzata full-text + filtri +│ │ │ ├── send.py # invio PEC +│ │ │ ├── archival.py # versamenti, RdV, DIP +│ │ │ ├── reports.py # export PDF/CSV +│ │ │ ├── permissions.py # permessi granulari per casella +│ │ │ ├── virtual_boxes.py # CRUD virtual box + assegnazioni +│ │ │ ├── notifications.py # CRUD canali e regole notifiche +│ │ │ └── ws.py # WebSocket endpoint (Socket.io) +│ │ │ +│ │ ├── models/ ← SQLAlchemy ORM models (1:1 con tabelle) +│ │ │ ├── tenant.py +│ │ │ ├── user.py +│ │ │ ├── mailbox.py +│ │ │ ├── message.py +│ │ │ ├── receipt.py +│ │ │ ├── archival.py +│ │ │ ├── permission.py # mailbox_permissions +│ │ │ ├── virtual_box.py # virtual_boxes, rules, assignments +│ │ │ ├── notification.py # notification_channels, rules, log +│ │ │ └── audit_log.py +│ │ │ +│ │ ├── schemas/ ← Pydantic schemas (request/response DTOs) +│ │ │ ├── auth.py +│ │ │ ├── mailbox.py +│ │ │ ├── message.py +│ │ │ ├── search.py # SearchQuery, SearchResult +│ │ │ ├── permission.py +│ │ │ ├── virtual_box.py +│ │ │ ├── notification.py +│ │ │ └── archival.py +│ │ │ +│ │ ├── services/ ← logica di business (nessun DB diretto) +│ │ │ ├── auth_service.py # JWT, TOTP, password hash +│ │ │ ├── mailbox_service.py # cifratura credenziali +│ │ │ ├── message_service.py # ricerca, etichette, stati +│ │ │ ├── search_service.py # query builder FTS + filtri compositi +│ │ │ ├── permission_service.py # verifica accesso utente/casella +│ │ │ ├── virtual_box_service.py # applica filtri VBox a query messaggi +│ │ │ ├── notification_service.py # valuta regole + dispatcha notifiche +│ │ │ ├── send_service.py # invio SMTP, salvataggio ricevute +│ │ │ └── archival_service.py # generazione SIP, chiamate conservatore +│ │ │ +│ │ ├── core/ ← utilities trasversali +│ │ │ ├── security.py # cifratura AES-256-GCM, hashing +│ │ │ ├── exceptions.py # eccezioni applicative custom +│ │ │ ├── pagination.py +│ │ │ └── logging.py # structured logging (JSON) +│ │ │ +│ │ └── websocket/ +│ │ └── manager.py # gestione connessioni WS per tenant +│ │ +│ └── tests/ +│ ├── unit/ +│ │ ├── test_auth_service.py +│ │ └── test_security.py +│ ├── integration/ +│ │ ├── test_api_auth.py +│ │ ├── test_api_messages.py +│ │ └── conftest.py # fixture DB in-memory (SQLite / testcontainers) +│ └── e2e/ # (opzionale, gestiti da Playwright lato frontend) +│ +├── worker/ ═══════════════════════════════ +│ │ ← BullMQ worker (Node.js / Python Celery alt.) +│ │ Scelta: Python (arq) per non aggiungere runtime +│ ├── Dockerfile +│ ├── pyproject.toml +│ │ +│ └── app/ +│ ├── main.py # entrypoint arq / asyncio loop +│ ├── config.py +│ │ +│ ├── imap/ ← IMAP sync engine +│ │ ├── pool.py # MailboxPool: gestisce N connessioni IMAP +│ │ ├── connection.py # IMAPConnection: IDLE + polling fallback +│ │ ├── sync.py # sincronizzazione UID, fetch envelope + body +│ │ └── reconnect.py # strategia backoff esponenziale +│ │ +│ ├── parsers/ ← parser PEC (lib interna, riusata anche da backend) +│ │ ├── pec_parser.py # estrae tipo messaggio PEC, ricevute EML-in-EML +│ │ ├── eml_parser.py # parsing MIME generico +│ │ └── receipt_extractor.py # estrae campi da ricevute (tipo, timestamp, msgid) +│ │ +│ ├── smtp/ ← invio SMTP +│ │ ├── sender.py # connessione STARTTLS/SSL, invio, retry +│ │ └── receipt_watcher.py # attende ricevuta accettazione/consegna post-invio +│ │ +│ ├── archival/ ← archiviazione sostitutiva +│ │ ├── sip_builder.py # genera pacchetto SIP (UNI SInCRO) +│ │ ├── conservatore_client.py # client HTTP verso API conservatore AgID +│ │ └── rdv_processor.py # processa ricevuta di versamento (RdV) +│ │ +│ ├── notifications/ ← dispatcher notifiche async +│ │ ├── dispatcher.py # valuta regole e sceglie canali +│ │ ├── channels/ +│ │ │ ├── webhook.py # HTTP POST con payload JSON + HMAC signature +│ │ │ ├── email_smtp.py # invio email via SMTP (aiosmtplib) +│ │ │ ├── telegram.py # Telegram Bot API (sendMessage) +│ │ │ └── whatsapp.py # Meta Cloud API / Twilio WhatsApp +│ │ └── retry.py # retry con backoff per canali falliti +│ │ +│ ├── search/ ← indicizzazione full-text +│ │ ├── indexer.py # aggiorna search_vector su nuovo messaggio +│ │ └── text_extractor.py # estrae testo da PDF/DOCX allegati (Tika HTTP) +│ │ +│ ├── jobs/ ← definizione job BullMQ / arq +│ │ ├── sync_mailbox.py # job: sincronizza singola casella +│ │ ├── send_pec.py # job: invia PEC con retry +│ │ ├── archive_batch.py # job: versamento batch verso conservatore +│ │ ├── generate_report.py # job: genera PDF/CSV report +│ │ ├── dispatch_notification.py # job: invia notifiche per un evento PEC +│ │ └── index_message.py # job: estrae testo allegati e aggiorna FTS +│ │ +│ └── tests/ +│ ├── unit/ +│ │ ├── test_pec_parser.py +│ │ ├── test_sip_builder.py +│ │ ├── test_notification_rules.py +│ │ └── test_virtual_box_filter.py +│ └── integration/ +│ ├── test_imap_sync.py # usa GreenMail / Mailhog mock IMAP +│ └── test_notification_dispatch.py +│ +├── frontend/ ═══════════════════════════════ +│ │ ← React 18 + TypeScript + Vite +│ ├── Dockerfile +│ ├── package.json +│ ├── tsconfig.json +│ ├── vite.config.ts +│ │ +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ │ +│ │ ├── api/ ← client HTTP (axios instances per risorsa) +│ │ │ ├── client.ts # axios base + interceptor JWT refresh +│ │ │ ├── auth.api.ts +│ │ │ ├── messages.api.ts +│ │ │ ├── search.api.ts # ricerca avanzata +│ │ │ ├── mailboxes.api.ts +│ │ │ ├── permissions.api.ts +│ │ │ ├── virtual_boxes.api.ts +│ │ │ ├── notifications.api.ts +│ │ │ └── archival.api.ts +│ │ │ +│ │ ├── store/ ← Zustand stores +│ │ │ ├── auth.store.ts +│ │ │ ├── mailbox.store.ts +│ │ │ ├── inbox.store.ts +│ │ │ └── search.store.ts # stato ricerca (query, filtri, risultati) +│ │ │ +│ │ ├── pages/ +│ │ │ ├── Login/ +│ │ │ ├── Inbox/ # lista messaggi PEC (filtra per VBox se assegnato) +│ │ │ ├── Search/ # ricerca avanzata con pannello filtri +│ │ │ ├── MessageDetail/ # dettaglio + ricevute + allegati +│ │ │ ├── Compose/ # composizione invio PEC +│ │ │ ├── Mailboxes/ # gestione caselle (admin) +│ │ │ ├── Permissions/ # gestione permessi per casella (admin) +│ │ │ ├── VirtualBoxes/ # gestione virtual box (admin) +│ │ │ ├── Users/ # gestione utenti (admin) +│ │ │ ├── Notifications/ # canali e regole notifiche (admin) +│ │ │ ├── Archival/ # log versamenti + DIP +│ │ │ └── Reports/ # dashboard + export +│ │ │ +│ │ ├── components/ ← UI atomici riusabili +│ │ │ ├── PecBadge/ # badge stato PEC (Inviata/Accettata/Consegnata) +│ │ │ ├── ReceiptTree/ # visualizza gerarchia ricevute EML-in-EML +│ │ │ ├── AttachmentList/ +│ │ │ ├── SearchBar/ # barra ricerca con suggerimenti +│ │ │ ├── SearchFilters/ # pannello filtri avanzati (collapsible) +│ │ │ ├── VirtualBoxBadge/ # indica se l'utente è in una VBox +│ │ │ └── NotificationChannelIcon/ # icona canale (webhook/email/telegram/whatsapp) +│ │ │ +│ │ ├── hooks/ +│ │ │ ├── useWebSocket.ts # Socket.io hook, aggiorna inbox in real-time +│ │ │ ├── useAuth.ts +│ │ │ ├── useSearch.ts # debounce, history, highlight risultati +│ │ │ ├── usePermissions.ts # controlla permessi utente corrente +│ │ │ └── usePagination.ts +│ │ │ +│ │ └── types/ ← SOLO tipi frontend-specific +│ │ └── ui.types.ts +│ │ +│ └── tests/ +│ ├── unit/ # Vitest + Testing Library +│ └── e2e/ # Playwright +│ +├── shared/ ═══════════════════════════════ +│ │ ← Tipi/contratti condivisi tra frontend e backend +│ │ Generati da OpenAPI schema o scritti a mano +│ ├── types/ +│ │ ├── api.types.ts # DTOs generati dall'OpenAPI spec del backend +│ │ ├── pec.types.ts # enumerazioni stati PEC, tipi ricevute +│ │ └── archival.types.ts # tipi SIP, RdV +│ └── README.md +│ +├── database/ ═══════════════════════════════ +│ │ ← Script SQL stand-alone, seeds, fixtures +│ ├── init/ +│ │ └── 00_extensions.sql # CREATE EXTENSION pgcrypto, uuid-ossp +│ ├── seeds/ +│ │ ├── dev_tenant.sql # tenant e utente demo per sviluppo locale +│ │ └── test_fixtures.sql +│ └── backups/ # (gitignored) dump locali +│ +├── infra/ ═══════════════════════════════ +│ │ ← Infrastructure as Code +│ ├── nginx/ +│ │ ├── nginx.conf # reverse proxy, rate limiting, TLS termination +│ │ └── conf.d/ +│ │ └── pecflow.conf +│ ├── redis/ +│ │ └── redis.conf # maxmemory, eviction policy +│ ├── prometheus/ +│ │ └── prometheus.yml +│ └── grafana/ +│ └── dashboards/ +│ └── pecflow.json +│ +└── docs/ ═══════════════════════════════ + ├── api/ ← OpenAPI spec generata (non commitare auto-gen) + ├── adr/ ← Architecture Decision Records + │ ├── ADR-001-multitenancy.md + │ ├── ADR-002-credential-encryption.md + │ └── ADR-003-imap-concurrency.md + └── diagrams/ + ├── c4-context.png + ├── c4-container.png + └── db-schema.png +``` + +--- + +## 2. Modello Dati + +### Strategia multi-tenancy + +Si usa **row-level con `tenant_id`** su ogni tabella applicativa (vedi ADR-001 in +sezione 4 per la motivazione completa). Le policy RLS PostgreSQL vengono attivate +come secondo strato di difesa. + +### DDL Completo + +```sql +-- ============================================================ +-- ESTENSIONI +-- ============================================================ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_bytes per nonce AES + + +-- ============================================================ +-- 1. TENANTS +-- Ogni organizzazione cliente del SaaS +-- ============================================================ +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + slug VARCHAR(63) NOT NULL UNIQUE, -- usato come subdomain: acme.pecflow.it + name VARCHAR(255) NOT NULL, + plan VARCHAR(50) NOT NULL DEFAULT 'starter', -- starter|pro|enterprise + is_active BOOLEAN NOT NULL DEFAULT TRUE, + max_mailboxes INT NOT NULL DEFAULT 5, + max_users INT NOT NULL DEFAULT 10, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_tenants_slug ON tenants (slug); + + +-- ============================================================ +-- 2. USERS +-- ============================================================ +CREATE TYPE user_role AS ENUM ('super_admin', 'admin', 'supervisor', 'operator', 'readonly'); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + password_hash TEXT NOT NULL, -- bcrypt, work factor 12 + full_name VARCHAR(255) NOT NULL, + role user_role NOT NULL DEFAULT 'operator', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + totp_secret TEXT, -- cifrato con app key + totp_enabled BOOLEAN NOT NULL DEFAULT FALSE, + last_login_at TIMESTAMPTZ, + failed_login_count INT NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_user_email_tenant UNIQUE (tenant_id, email) +); + +CREATE INDEX idx_users_tenant ON users (tenant_id); +CREATE INDEX idx_users_email ON users (email); + +-- RLS: un utente vede solo gli utenti del proprio tenant +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +CREATE POLICY users_tenant_isolation ON users + USING (tenant_id = current_setting('app.current_tenant_id')::UUID); + + +-- ============================================================ +-- 3. REFRESH TOKENS +-- ============================================================ +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, -- SHA-256 del token raw + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + user_agent TEXT, + ip_address INET +); + +CREATE INDEX idx_rt_user ON refresh_tokens (user_id); +CREATE INDEX idx_rt_expires ON refresh_tokens (expires_at) WHERE revoked_at IS NULL; + + +-- ============================================================ +-- 4. MAILBOXES (caselle PEC) +-- ============================================================ +CREATE TYPE mailbox_status AS ENUM ('active', 'paused', 'error', 'deleted'); + +CREATE TABLE mailboxes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email_address VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + provider VARCHAR(100), -- 'aruba', 'namirial', 'legalmail', ecc. + + -- credenziali IMAP/SMTP cifrate (AES-256-GCM) + -- formato colonna: base64(nonce || ciphertext || tag) + imap_host_enc TEXT NOT NULL, + imap_port_enc TEXT NOT NULL, + imap_user_enc TEXT NOT NULL, + imap_pass_enc TEXT NOT NULL, + imap_use_ssl BOOLEAN NOT NULL DEFAULT TRUE, + + smtp_host_enc TEXT NOT NULL, + smtp_port_enc TEXT NOT NULL, + smtp_user_enc TEXT NOT NULL, + smtp_pass_enc TEXT NOT NULL, + smtp_use_tls BOOLEAN NOT NULL DEFAULT TRUE, + + status mailbox_status NOT NULL DEFAULT 'active', + last_sync_at TIMESTAMPTZ, + last_sync_uid BIGINT, -- ultimo UID IMAP sincronizzato + sync_error_msg TEXT, + sync_error_count INT NOT NULL DEFAULT 0, + + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_mailbox_email_tenant UNIQUE (tenant_id, email_address) +); + +CREATE INDEX idx_mailboxes_tenant ON mailboxes (tenant_id); +CREATE INDEX idx_mailboxes_status ON mailboxes (status) WHERE status = 'active'; + +ALTER TABLE mailboxes ENABLE ROW LEVEL SECURITY; +CREATE POLICY mailboxes_tenant_isolation ON mailboxes + USING (tenant_id = current_setting('app.current_tenant_id')::UUID); + + +-- ============================================================ +-- 5. MESSAGES (PEC ricevute e inviate) +-- ============================================================ +CREATE TYPE pec_direction AS ENUM ('inbound', 'outbound'); +CREATE TYPE pec_state AS ENUM ( + -- outbound flow + 'draft', 'queued', 'sent', 'accepted', 'delivered', 'anomaly', 'failed', + -- inbound + 'received' +); +CREATE TYPE pec_msg_type AS ENUM ( + 'posta_certificata', -- messaggio originale PEC + 'accettazione', -- ricevuta di accettazione dal gestore mittente + 'non_accettazione', -- ricevuta di non accettazione + 'presa_in_carico', -- ricevuta intermedia alcuni provider + 'avvenuta_consegna', -- ricevuta di avvenuta consegna dal gestore destinatario + 'mancata_consegna', -- ricevuta di mancata consegna (timeout / errore) + 'errore_consegna', -- ricevuta di errore consegna + 'preavviso_mancata_consegna', + 'rilevazione_virus', + 'unknown' +); + +CREATE TABLE messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, + + -- identificatori + message_id_header TEXT, -- valore header Message-ID + imap_uid BIGINT, -- UID IMAP nella casella + imap_folder VARCHAR(255) NOT NULL DEFAULT 'INBOX', + + direction pec_direction NOT NULL, + pec_type pec_msg_type NOT NULL DEFAULT 'posta_certificata', + state pec_state NOT NULL, + + -- busta PEC + subject TEXT, + from_address VARCHAR(255), + to_addresses TEXT[], -- array destinatari + cc_addresses TEXT[], + sent_at TIMESTAMPTZ, -- data invio originale + received_at TIMESTAMPTZ, -- data ricezione IMAP + size_bytes BIGINT, + + -- corpo + body_text TEXT, -- testo plain estratto + body_html TEXT, -- HTML estratto (solo per visualizzazione) + has_attachments BOOLEAN NOT NULL DEFAULT FALSE, + + -- per messaggi outbound: riferimento alla PEC originale di cui sono ricevuta + parent_message_id UUID REFERENCES messages(id), + + -- flag operativi + is_read BOOLEAN NOT NULL DEFAULT FALSE, + is_starred BOOLEAN NOT NULL DEFAULT FALSE, + is_archived BOOLEAN NOT NULL DEFAULT FALSE, + archived_at TIMESTAMPTZ, + + -- raw storage path (allegati e raw EML) + raw_eml_path TEXT, -- percorso in object storage + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_messages_tenant ON messages (tenant_id); +CREATE INDEX idx_messages_mailbox ON messages (mailbox_id); +CREATE INDEX idx_messages_state ON messages (state); +CREATE INDEX idx_messages_received_at ON messages (received_at DESC); +CREATE INDEX idx_messages_parent ON messages (parent_message_id) WHERE parent_message_id IS NOT NULL; +CREATE INDEX idx_messages_imap_uid ON messages (mailbox_id, imap_uid); +-- Full-text search sul soggetto +CREATE INDEX idx_messages_subject_fts ON messages USING GIN (to_tsvector('italian', COALESCE(subject, ''))); + +ALTER TABLE messages ENABLE ROW LEVEL SECURITY; +CREATE POLICY messages_tenant_isolation ON messages + USING (tenant_id = current_setting('app.current_tenant_id')::UUID); + + +-- ============================================================ +-- 6. ATTACHMENTS +-- ============================================================ +CREATE TABLE attachments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + filename VARCHAR(512) NOT NULL, + content_type VARCHAR(255), + size_bytes BIGINT, + storage_path TEXT NOT NULL, -- percorso MinIO/S3: tenants/{tid}/msgs/{mid}/{filename} + checksum_sha256 CHAR(64), -- integrità + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_attachments_message ON attachments (message_id); + + +-- ============================================================ +-- 7. SEND_JOBS (tracciamento invii SMTP) +-- ============================================================ +CREATE TYPE send_job_status AS ENUM ('pending', 'sending', 'sent', 'failed', 'retrying'); + +CREATE TABLE send_jobs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + mailbox_id UUID NOT NULL REFERENCES mailboxes(id), + message_id UUID REFERENCES messages(id), -- NULL fino all'invio riuscito + status send_job_status NOT NULL DEFAULT 'pending', + attempt_count INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 5, + next_retry_at TIMESTAMPTZ, + last_error TEXT, + queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id) +); + +CREATE INDEX idx_sendjobs_tenant ON send_jobs (tenant_id); +CREATE INDEX idx_sendjobs_status ON send_jobs (status, next_retry_at) WHERE status IN ('pending','retrying'); + + +-- ============================================================ +-- 8. ARCHIVAL_BATCHES (versamenti verso conservatore AgID) +-- ============================================================ +CREATE TYPE archival_status AS ENUM ( + 'pending', -- in coda + 'building_sip', -- generazione pacchetto SIP + 'uploading', -- trasferimento al conservatore + 'uploaded', -- ricevuta di versamento ricevuta (RdV) + 'confirmed', -- conservatore ha confermato presa in carico + 'rejected', -- conservatore ha rifiutato il versamento + 'failed' -- errore tecnico non recuperabile +); + +CREATE TABLE archival_batches ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + conservatore_id VARCHAR(100) NOT NULL, -- codice conservatore (es. 'aruba-cons', 'docuvision') + status archival_status NOT NULL DEFAULT 'pending', + + sip_path TEXT, -- path del pacchetto SIP generato + sip_checksum CHAR(64), -- SHA-256 del SIP + + -- risposta dal conservatore + versamento_id TEXT, -- ID assegnato dal conservatore + rdv_received_at TIMESTAMPTZ, + rdv_path TEXT, -- path della RdV archiviata + rdv_checksum CHAR(64), + + attempt_count INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 3, + next_retry_at TIMESTAMPTZ, + last_error TEXT, + + period_from DATE NOT NULL, -- periodo di riferimento del versamento + period_to DATE NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_archival_tenant ON archival_batches (tenant_id); +CREATE INDEX idx_archival_status ON archival_batches (status, next_retry_at); + +-- Tabella pivot: quali messaggi sono inclusi in quale batch +CREATE TABLE archival_batch_messages ( + batch_id UUID NOT NULL REFERENCES archival_batches(id) ON DELETE CASCADE, + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + PRIMARY KEY (batch_id, message_id) +); + +-- DIP (Dissemination Information Package): recupero da conservatore +CREATE TABLE archival_dips ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + batch_id UUID REFERENCES archival_batches(id), + requested_by UUID REFERENCES users(id), + dip_path TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'requested', + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + received_at TIMESTAMPTZ +); + + +-- ============================================================ +-- 9. AUDIT_LOG +-- Immutabile (no UPDATE/DELETE via applicazione) +-- ============================================================ +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + tenant_id UUID REFERENCES tenants(id), + user_id UUID REFERENCES users(id), + action VARCHAR(100) NOT NULL, -- 'message.read', 'mailbox.create', ecc. + resource_type VARCHAR(100), + resource_id UUID, + ip_address INET, + user_agent TEXT, + payload JSONB, -- parametri dell'azione (sanitizzati) + outcome VARCHAR(20) NOT NULL DEFAULT 'success', -- success|failure + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Partizionamento per range (mese) per gestire volumi grandi senza purge +-- In Postgres 14+ si può fare: PARTITION BY RANGE (occurred_at) +-- Per semplicità in v1, si aggiunge un indice composito +CREATE INDEX idx_audit_tenant_date ON audit_log (tenant_id, occurred_at DESC); +CREATE INDEX idx_audit_user ON audit_log (user_id, occurred_at DESC); +CREATE INDEX idx_audit_action ON audit_log (action); + +-- Blocca UPDATE/DELETE anche per DBA (si può bypassare solo con superuser esplicito) +ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY; +CREATE POLICY audit_no_delete ON audit_log FOR DELETE USING (FALSE); +CREATE POLICY audit_no_update ON audit_log FOR UPDATE USING (FALSE); +CREATE POLICY audit_tenant_read ON audit_log FOR SELECT + USING (tenant_id = current_setting('app.current_tenant_id')::UUID); + + +-- ============================================================ +-- 10. LABELS / ETICHETTE (tagging messaggi) +-- ============================================================ +CREATE TABLE labels ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + color CHAR(7), -- hex color #RRGGBB + CONSTRAINT uq_label_name_tenant UNIQUE (tenant_id, name) +); + +CREATE TABLE message_labels ( + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, label_id) +); + + +-- ============================================================ +-- TRIGGER: updated_at automatico +-- ============================================================ +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN NEW.updated_at = NOW(); RETURN NEW; END; +$$; + +DO $$ DECLARE t TEXT; BEGIN + FOREACH t IN ARRAY ARRAY['tenants','users','mailboxes','messages','archival_batches'] LOOP + EXECUTE format('CREATE TRIGGER trg_%s_updated_at + BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION set_updated_at()', t, t); + END LOOP; +END $$; +``` + +--- + +## 3. Piano di Sviluppo a Fasi + +### Fase 1 – Fondamenta (3 settimane) + +**Obiettivo:** infrastruttura funzionante, autenticazione sicura, deploy locale. + +**Task:** +- [ ] Inizializzare monorepo con struttura cartelle, `.gitignore`, `Makefile` +- [ ] `docker-compose.yml` con PostgreSQL 16, Redis 7, MinIO (S3 local), Nginx +- [ ] Backend FastAPI: struttura app, config pydantic-settings, health endpoint +- [ ] Alembic: prima migrazione con tutte le tabelle DDL (sezione 2) +- [ ] Modello `tenants` + `users` + ORM SQLAlchemy corrispondente +- [ ] Auth API: `POST /auth/login` (email+password), JWT access token (15 min) + refresh token (30 gg) +- [ ] Middleware RLS: `set_config('app.current_tenant_id', ...)` su ogni request +- [ ] API 2FA TOTP: `POST /auth/totp/setup`, `POST /auth/totp/verify` +- [ ] Rate limiting su endpoint auth (slowapi) +- [ ] CRUD utenti base (admin crea/disabilita utenti dello stesso tenant) +- [ ] CI GitHub Actions: lint (ruff, mypy), test (pytest), build Docker +- [ ] Seed script: tenant demo + utente admin per dev locale + +**Definition of Done:** +- Login, refresh token e 2FA funzionanti e coperti da test di integrazione +- `make dev` porta in piedi tutto lo stack locale +- CI verde su PR + +--- + +### Fase 2 – IMAP Sync Engine (4 settimane) + +**Obiettivo:** connessione IMAP stabile a N caselle in parallelo, sincronizzazione messaggi. + +**Task:** +- [ ] CRUD caselle PEC (`POST /mailboxes`, cifratura credenziali AES-256-GCM) +- [ ] Worker arq: entrypoint, connessione Redis, worker health check +- [ ] `MailboxPool`: avvia un task async per ogni casella `status=active` all'avvio +- [ ] `IMAPConnection`: connessione IMAP via aioimaplib, gestione IDLE con heartbeat 28 min (RFC 2177) +- [ ] Polling fallback: se IDLE non supportato, polling ogni 60 secondi +- [ ] Backoff esponenziale su disconnessione (1s → 2s → 4s → max 5 min) +- [ ] Fetch UID list + envelope per messaggi nuovi (UID > `last_sync_uid`) +- [ ] Salvataggio `messages` con campi base, aggiornamento `last_sync_uid` +- [ ] Download body e raw EML su MinIO: `tenants/{tid}/raw/{mailbox_id}/{uid}.eml` +- [ ] Aggiornamento stato casella: `sync_error_count`, `status=error` dopo N fallimenti +- [ ] Notifica WebSocket su nuovo messaggio: evento `mailbox:new_message` al tenant +- [ ] Test di integrazione con Greenmail (server IMAP mock Java) via Docker + +**Definition of Done:** +- Connessione a casella Aruba PEC reale in ambiente di test (sandbox) +- Nuovi messaggi compaiono in DB entro 30 secondi dall'arrivo +- Riconnessione automatica verificata (kill connessione di rete e attesa recovery) + +--- + +### Fase 3 – Parser PEC & Tracking Ricevute (3 settimane) + +**Obiettivo:** classificare correttamente i messaggi PEC e collegare le ricevute. + +**Task:** +- [ ] `pec_parser.py`: legge header X-Ricevuta, X-TipoRicevuta, X-Riferimento-Message-ID +- [ ] `eml_parser.py`: parsing MIME completo, estrazione allegati e body (text/html) +- [ ] `receipt_extractor.py`: estrae EML allegato dentro ricevuta (EML-in-EML annidato) +- [ ] Mappatura `pec_msg_type` da header PEC a enum DB +- [ ] Collegamento `parent_message_id`: associa ricevuta al messaggio originale via `X-Riferimento-Message-ID` +- [ ] State machine messaggi outbound: `sent→accepted→delivered` (o `anomaly`) +- [ ] Download e salvataggio allegati su MinIO, inserimento tabella `attachments` +- [ ] Test unitari parser con EML reali (fixture anonimizzate) per tutti i tipi di ricevuta +- [ ] Test regressione: parsing Aruba / Namirial / Legalmail (formato header leggermente diverso per provider) + +**Definition of Done:** +- 100% dei tipi ricevuta classificati correttamente su un set di 50+ EML reali +- I messaggi outbound aggiornano stato automaticamente all'arrivo della ricevuta + +--- + +### Fase 4 – Invio SMTP (2 settimane) + +**Obiettivo:** invio PEC affidabile con retry e tracking. + +**Task:** +- [ ] API `POST /send`: validazione, creazione `send_job` e `message` in stato `draft`→`queued` +- [ ] Job `send_pec`: connessione SMTP STARTTLS/SSL via aiosmtplib +- [ ] Gestione `To`, `Cc` multipli, allegati, headers PEC obbligatori +- [ ] Salvataggio raw EML inviato su MinIO +- [ ] Retry con backoff: 5 tentativi (1 min → 5 min → 15 min → 1h → 4h) +- [ ] Dopo invio: avvia `receipt_watcher` – attende ricevuta di accettazione entro 24h +- [ ] Alert: se nessuna accettazione in 24h → stato `anomaly` + notifica WS + +**Definition of Done:** +- Invio PEC funzionante verso casella test con ricevuta di accettazione verificata +- Retry verificato simulando errore SMTP temporaneo + +--- + +### Fase 5 – Frontend (5 settimane) + +**Obiettivo:** interfaccia utente completa per operatori e admin. + +**Task:** +- [ ] Setup Vite + React 18 + TypeScript + Tailwind CSS + shadcn/ui +- [ ] Layout base: sidebar navigazione, header tenant +- [ ] Pagina Login + 2FA TOTP (QR code, OTP input) +- [ ] Axios client: interceptor refresh token (refresh silenzioso su 401) +- [ ] Hook `useWebSocket` (Socket.io): aggiorna inbox in real-time +- [ ] Inbox: lista messaggi paginata, filtri (casella, stato, etichetta, testo), badge PEC state +- [ ] Dettaglio messaggio: corpo, allegati scaricabili, tree ricevute (componente `ReceiptTree`) +- [ ] Composizione PEC: form To/Cc/Subject/Body/Allegati, preview, invio +- [ ] Gestione Caselle (admin): CRUD, test connessione, stato sync +- [ ] Gestione Utenti (admin): CRUD, cambio ruolo, reset password +- [ ] Etichette: crea/assegna/filtra +- [ ] Internazionalizzazione: i18next (solo italiano per v1) +- [ ] E2E Playwright: login, lettura PEC, invio PEC + +**Definition of Done:** +- Demo completa con un operatore che riceve e invia PEC senza toccare l'API direttamente +- Lighthouse score ≥ 80 su Performance e Accessibility + +--- + +### Fase 6 – Archiviazione Sostitutiva (4 settimane) + +**Obiettivo:** versamento PEC verso conservatore AgID, recupero DIP. + +**Task:** +- [ ] `sip_builder.py`: generazione pacchetto SIP conforme UNI SInCRO 11386:2023 + - Indice del pacchetto (XML) + - Allegati originali + ricevute + - Calcolo hash SHA-256 per ogni file +- [ ] `conservatore_client.py`: client HTTP per API conservatore (mock locale in dev) + - `POST /versamento` (upload SIP) + - `GET /versamento/{id}` (polling stato) + - `GET /dip/{id}` (richiesta DIP) +- [ ] `rdv_processor.py`: parsing RdV XML, salvataggio in `archival_batches` +- [ ] Job `archive_batch`: seleziona messaggi `archived=false` nel periodo, crea SIP, carica +- [ ] Retry versamenti falliti: 3 tentativi con backoff progressivo +- [ ] API `GET /archival/batches`, `GET /archival/batches/{id}/rdv` +- [ ] API `POST /archival/dip` (richiesta DIP) +- [ ] Pagina frontend Archivio: log versamenti, download RdV, richiesta DIP +- [ ] Configurazione per-tenant del conservatore (endpoint, credenziali cifrate) + +**Definition of Done:** +- Versamento completato end-to-end verso conservatore mock +- RdV XML salvata e scaricabile dal frontend + +--- + +### Fase 7 – Dashboard e Reportistica (2 settimane) + +**Obiettivo:** visibilità operativa e compliance. + +**Task:** +- [ ] API `/reports/summary`: KPI real-time (PEC ricevute/inviate oggi, anomalie, tasso consegna) +- [ ] API `/reports/export`: generazione PDF (WeasyPrint) e CSV +- [ ] Dashboard frontend: grafici (Recharts), filtri per periodo e casella +- [ ] Audit log viewer (admin): ricerca per utente/azione/data +- [ ] Alerting: soglie configurabili (es. >10% anomalie → email notifica admin) + +**Definition of Done:** +- Export PDF generato correttamente con dati reali +- Dashboard mostra dati aggiornati in < 2s + +--- + +### Fase 8 – Hardening, Test, Go-Live (3 settimane) + +**Obiettivo:** sistema production-ready. + +**Task:** +- [ ] Penetration test interno: OWASP Top 10 check (IDOR, injection, broken auth) +- [ ] Secret scan CI: Gitleaks / Trufflesecurity su ogni commit +- [ ] Dependency audit: `pip audit`, `npm audit` → zero critical +- [ ] Copertura test: ≥ 80% backend, ≥ 60% frontend +- [ ] Load test: Locust – 50 utenti concorrenti, 10 caselle sincronizzate +- [ ] Backup automatico PostgreSQL (pg_dump daily → MinIO, retention 30gg) +- [ ] Monitoring: Prometheus + Grafana dashboard (latency API, queue size, IMAP errors) +- [ ] Log aggregation: Loki o ELK stack +- [ ] Runbook operativo: come aggiungere tenant, debug IMAP, recovery da errore conservatore +- [ ] GDPR: endpoint `DELETE /tenants/{id}` (cancellazione dati completa + audit trail) + +**Definition of Done:** +- Zero vulnerability critiche +- SLA target: API p95 < 500ms, uptime 99.5% +- Runbook revisionato dal team + +--- + +## 4. Decisioni Architetturali + +--- + +### ADR-001 – Multi-tenancy: Schema-per-tenant vs Row-level con tenant_id + +**Opzione A – Schema-per-tenant (un PostgreSQL schema per ogni organizzazione)** + +*Pro:* +- Isolamento totale dei dati: impossibile data leak cross-tenant per bug SQL +- Backup e restore per singolo tenant molto semplici +- Possibilità di migrare un tenant su un DB separato senza refactoring + +*Contro:* +- Alembic migrations vanno applicate a tutti gli schema (N schema × migrazione) +- Connection pooling complesso: PgBouncer deve gestire schema switching +- Difficile fare query aggregate cross-tenant (es. monitoraggio globale SaaS) +- Overhead operativo significativo oltre i 100 tenant + +**Opzione B – Row-level con `tenant_id` + PostgreSQL RLS** + +*Pro:* +- Un solo schema, migrations applicate una volta +- Query cross-tenant per operazioni di sistema (monitoring, billing) +- Molto più semplice da gestire con Alembic e ORM +- Scala bene fino a decine di migliaia di tenant + +*Contro:* +- Bug nell'impostazione `current_tenant_id` può causare data leak → mitigato con RLS come secondo livello +- Restore di singolo tenant richiede WHERE clause su dump + +**Raccomandazione: Opzione B (row-level + RLS)** + +Per un SaaS B2B con caselle PEC, il numero di tenant nel medio termine è nell'ordine delle centinaia, non decine di migliaia. Il rischio principale (data leak cross-tenant) è mitigato da due strati indipendenti: +1. Applicativo: ogni query include `WHERE tenant_id = :current_tenant_id` +2. DB: PostgreSQL RLS come safety net, impostata tramite `SET LOCAL app.current_tenant_id = '...'` in ogni transazione + +Il vantaggio operativo (migrazioni semplici, query di monitoring, pool unico) supera i rischi, a condizione di avere test di integrazione che verificano il corretto isolamento. + +--- + +### ADR-002 – Cifratura credenziali IMAP/SMTP a riposo + +**Opzione A – Cifratura applicativa (AES-256-GCM)** + +Le credenziali sono cifrate nel layer applicativo prima di essere scritte in DB. +La chiave master è in `SECRET_KEY` (variabile d'ambiente, ruotabile). +Formato storage: `base64(nonce_12byte || ciphertext || tag_16byte)`. + +*Pro:* la chiave non è mai in DB; un dump del DB senza la chiave è inutile. +*Contro:* rotazione chiave richiede re-cifratura di tutte le righe. + +**Opzione B – pgcrypto `pgp_sym_encrypt`** + +Cifratura dentro PostgreSQL con passphrase. + +*Pro:* semplice da implementare. +*Contro:* la chiave transita sulla connessione DB; meno controllo sul key management. + +**Opzione C – HashiCorp Vault / AWS KMS (envelope encryption)** + +Vault genera una data encryption key (DEK) per ogni casella; la DEK è cifrata con key encryption key (KEK) gestita da Vault. + +*Pro:* key rotation automatica, audit trail accessi chiave, compliance elevata. +*Contro:* dipendenza infrastrutturale aggiuntiva, complessità setup per v1. + +**Raccomandazione: Opzione A per v1, con migrazione verso C in Fase 8** + +In v1 si usa AES-256-GCM con chiave in variabile d'ambiente (gestita via Docker Secret o Kubernetes Secret). L'interfaccia di cifratura è astratta in `core/security.py`, quindi la migrazione a Vault è trasparente al resto del codice. La rotazione della chiave viene gestita con un management command dedicato. + +--- + +### ADR-003 – Concorrenza IMAP su N caselle + +**Opzione A – Un processo per casella (multiprocessing)** + +Un processo OS per ogni casella PEC attiva. + +*Pro:* isolamento totale; crash di una casella non impatta le altre. +*Contro:* overhead di memoria elevato (N × ~50MB), difficile scalare oltre ~50 caselle per host. + +**Opzione B – Thread pool** + +Un thread per casella dentro un unico processo Python. + +*Contro:* GIL Python limita parallelismo CPU-bound; librerie IMAP non sempre thread-safe. + +**Opzione C – Async task pool (asyncio) – RACCOMANDATO** + +Un singolo processo async con N coroutine IMAP, una per casella. La libreria `aioimaplib` è nativa async. Ogni casella gira in un loop IDLE / polling come coroutine indipendente. + +*Pro:* memory footprint bassissimo (< 10MB per casella), migliaia di caselle per host, codice semplice. +*Contro:* un bug che blocca l'event loop impatta tutte le caselle → mitigato con timeout su ogni operazione IMAP. + +**Implementazione consigliata:** + +``` +MailboxPool (asyncio) + ├── asyncio.Task per casella #1 → IMAPConnection (IDLE loop) + ├── asyncio.Task per casella #2 → IMAPConnection (IDLE loop) + └── ... +``` + +Il `MailboxPool` monitora ogni task con `asyncio.shield` + timeout. Se un task muore, viene riavviato con backoff esponenziale. Un semaforo limita il numero di fetch simultanei (es. max 10) per evitare saturazione banda. + +--- + +### ADR-004 – Retry strategy versamenti verso conservatore AgID + +Il conservatore AgID può essere temporaneamente non disponibile (manutenzione, overload). Un versamento fallito non è recuperabile in modo autonomo dal conservatore: il sistema deve ritentare attivamente. + +**Strategia:** + +1. **Backoff esponenziale con jitter:** tentativo 1 subito, poi 1h, 4h, 24h (max 3 retry oltre il primo) +2. **Dead letter queue:** dopo tutti i retry falliti, il batch va in stato `failed` e genera un alert email all'admin del tenant + al team ops PecFlow +3. **Idempotenza:** prima di ogni retry, verificare se il conservatore ha già ricevuto il versamento (`GET /versamento/{id}`) per evitare duplicati +4. **Finestra di versamento:** i versamenti periodici (es. mensili) devono completarsi entro la scadenza normativa; se il sistema prevede che non ce la farà, genera alert anticipato a 72h dalla scadenza +5. **Circuit breaker:** se il conservatore fallisce 5 volte in 1 ora, si sospendono tutti i versamenti verso quel conservatore per 30 minuti (evita tempesta di retry) + +--- + +### ADR-005 – Storage allegati e raw EML + +**Opzione A – Filesystem locale** + +*Pro:* zero dipendenze, semplicissimo. +*Contro:* non scala orizzontalmente (worker su host diversi non vedono i file); backup complesso. + +**Opzione B – PostgreSQL BYTEA / Large Object** + +*Contro:* gonfia il DB, WAL enorme, backup lenti, nessun vantaggio rispetto a object storage. + +**Opzione C – Object Storage S3-compatible (MinIO in locale, S3/Wasabi in prod)** + +*Pro:* scala infinitamente, separazione DB/oggetti, backup indipendente, URL pre-firmati per download diretto dal browser senza passare per il backend, CDN-ready. +*Contro:* dipendenza aggiuntiva (MinIO in Docker Compose per dev). + +**Raccomandazione: Opzione C** + +Percorso oggetto: `{bucket}/tenants/{tenant_id}/mailboxes/{mailbox_id}/messages/{message_id}/{tipo}/{filename}` + +Dove `{tipo}` può essere `raw` (EML grezzo), `attachments`, `sip` (pacchetti SIP), `rdv`. + +I metadati (filename, content-type, checksum, path) sono in PostgreSQL nella tabella `attachments`. Il frontend riceve URL pre-firmati con scadenza 15 minuti per il download diretto. + +In ambiente di sviluppo locale, MinIO è incluso nel `docker-compose.yml` con credenziali fisse. In produzione si sostituisce con la variabile `S3_ENDPOINT_URL` che punta a S3 o a un provider compatibile. + +--- + +### ADR-006 – Strategia Full-Text Search + +**Opzione A – PostgreSQL FTS nativo (tsvector/tsquery)** + +Colonna `search_vector` di tipo `tsvector` aggiornata tramite trigger ad ogni INSERT/UPDATE su `messages`. Copre: oggetto, corpo, mittente, destinatari, nomi allegati. + +*Pro:* zero dipendenze aggiuntive, stesso DB già in stack, transazionale, indice GIN incluso nel backup. +*Contro:* ricerca su testo estratto dagli allegati (PDF, DOCX) richiede pre-processing separato; non scala quanto Elasticsearch su miliardi di documenti. + +**Opzione B – Elasticsearch / OpenSearch** + +Servizio dedicato con indici invertiti ottimizzati, supporto nativo a highlighting, fuzzy, suggerimenti. + +*Pro:* performance eccellente su grandi volumi, relevance scoring avanzato, analizzatori linguistici. +*Contro:* servizio aggiuntivo da gestire (heap JVM, snapshot, upgrade), sincronizzazione DB→ES può andare out-of-sync, licenza cambiata (OpenSearch è alternativa open). + +**Raccomandazione: Opzione A per v1-v2, revisione a v3** + +La ricerca FTS nativa di PostgreSQL è più che sufficiente per volumi tipici di PEC aziendali (< 5 milioni di messaggi). Il `search_vector` copre tutti i campi principali; il testo degli allegati viene estratto in background (Apache Tika via container Docker dedicato) e aggiunto al vettore. La migrazione a Elasticsearch in futuro è possibile senza modificare l'API pubblica, solo il `search_service.py`. + +--- + +### ADR-007 – Architettura notifiche multi-canale + +**Problema:** i canali di notifica (webhook, email, Telegram, WhatsApp) hanno latenza, affidabilità e costi di chiamata completamente diversi. Un fallimento su un canale non deve bloccare gli altri. + +**Architettura scelta: Fan-out asincrono per canale con retry indipendenti** + +``` +Evento PEC (es. message.received) + ↓ +notification_service.evaluate_rules() ← trova regole applicabili + ↓ +Per ogni regola corrispondente: + → enqueue job dispatch_notification (canale, payload, rule_id) + ↓ + worker/notifications/dispatcher.py + ├── webhook.py → HTTP POST + HMAC-SHA256 signature header + ├── email_smtp.py → template HTML via aiosmtplib + ├── telegram.py → Bot API sendMessage (MarkdownV2) + └── whatsapp.py → Meta Cloud API v18 / Twilio fallback + ↓ + notification_log (outcome: success|failed, attempt_count) +``` + +Ogni dispatch è un job arq indipendente con retry (max 3, backoff 5 min → 30 min → 2h). Il fallimento di Telegram non ritarda il webhook. Ogni canale ha il proprio circuit breaker (5 fail / 10 min → pausa 1h). + +**WhatsApp:** si usa Meta Cloud API (gratuita fino a 1000 conversazioni/mese) con fallback opzionale su Twilio per volumi maggiori. Le credenziali (token, phone_number_id) sono configurate per-tenant in `notification_channels`. + +**Telegram:** un bot per tenant (token nel canale) o bot condiviso con `chat_id` per-utente. Si raccomanda bot condiviso per semplicità operativa in v1. + +--- + +### ADR-008 – Strategia permessi e isolamento Virtual Box + +**Problema:** il sistema deve supportare sia accesso globale (admin vede tutto) sia accesso ristretto granulare (utente vede solo mail specifiche da casella specifica). + +**Strategia a due livelli:** + +**Livello 1 – Permessi per casella (`mailbox_permissions`)** +Ogni utente può avere permessi espliciti su una casella: `can_read`, `can_send`, `can_manage`. Gli admin hanno accesso implicito a tutte le caselle del proprio tenant. Gli operatori senza permessi espliciti non vedono nessuna casella. + +**Livello 2 – Virtual Box (filtro sui messaggi)** +Una Virtual Box è un filtro configurabile che, se assegnato a un utente, restringe ulteriormente i messaggi visibili *dentro* le caselle permesse. I criteri sono combinati in AND: l'utente vede solo i messaggi che soddisfano TUTTI i criteri della VBox assegnata. + +``` +Utente pbianchi + ├── mailbox_permissions: info@ → can_read=TRUE, can_send=FALSE + └── virtual_box_assignment: "Multe info@" + └── virtual_box_rules: + ├── mailbox_id = + └── subject_pattern = 'Multa' (ILIKE '%Multa%') + +→ pbianchi vede SOLO i messaggi di info@ con oggetto contenente "Multa" +``` + +L'isolamento è applicato nel `virtual_box_service.py` che inietta le clausole WHERE aggiuntive in ogni query messaggi. La stessa logica si applica alla ricerca avanzata: se l'utente ha una VBox, la ricerca non può uscire dal perimetro della VBox. + +--- + +## 2.2 DDL Aggiuntivo – Sistemi Avanzati + +```sql +-- ============================================================ +-- A. SEARCH VECTOR su MESSAGES +-- Colonna calcolata per full-text search su tutti i campi +-- ============================================================ + +-- Aggiunta della colonna search_vector alla tabella messages +ALTER TABLE messages ADD COLUMN IF NOT EXISTS + search_vector TSVECTOR + GENERATED ALWAYS AS ( + setweight(to_tsvector('italian', COALESCE(subject, '')), 'A') || + setweight(to_tsvector('italian', COALESCE(from_address, '')), 'B') || + setweight(to_tsvector('italian', array_to_string(COALESCE(to_addresses, '{}'), ' ')), 'B') || + setweight(to_tsvector('italian', COALESCE(body_text, '')), 'C') + ) STORED; + +CREATE INDEX idx_messages_search_vector ON messages USING GIN (search_vector); + +-- Aggiunta testo estratto degli allegati (aggiornato dal worker Tika) +ALTER TABLE attachments ADD COLUMN IF NOT EXISTS extracted_text TEXT; +ALTER TABLE attachments ADD COLUMN IF NOT EXISTS extraction_status VARCHAR(20) DEFAULT 'pending'; +-- extraction_status: pending | done | skipped (non testuale) | failed + +CREATE INDEX idx_attachments_fts + ON attachments USING GIN (to_tsvector('italian', COALESCE(extracted_text, ''))) + WHERE extracted_text IS NOT NULL; + + +-- ============================================================ +-- B. PERMESSI GRANULARI PER CASELLA +-- ============================================================ +-- Matrice permessi: per ogni (user, mailbox) definisce cosa può fare. +-- Un admin di tenant NON ha bisogno di record: accesso implicito. +-- Operatori e readonly DEVONO avere un record per vedere la casella. + +CREATE TABLE mailbox_permissions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, + + can_read BOOLEAN NOT NULL DEFAULT TRUE, -- legge messaggi + can_send BOOLEAN NOT NULL DEFAULT FALSE, -- invia PEC da questa casella + can_manage BOOLEAN NOT NULL DEFAULT FALSE, -- modifica configurazione casella + + granted_by UUID REFERENCES users(id), + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_perm_user_mailbox UNIQUE (user_id, mailbox_id) +); + +CREATE INDEX idx_mbperm_user ON mailbox_permissions (user_id); +CREATE INDEX idx_mbperm_mailbox ON mailbox_permissions (mailbox_id); +CREATE INDEX idx_mbperm_tenant ON mailbox_permissions (tenant_id); + + +-- ============================================================ +-- C. VIRTUAL BOX +-- ============================================================ + +-- Definizione di una Virtual Box (vista filtrata sui messaggi) +CREATE TABLE virtual_boxes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_vbox_name_tenant UNIQUE (tenant_id, name) +); + +CREATE INDEX idx_vbox_tenant ON virtual_boxes (tenant_id); + +-- Regole di filtro per una Virtual Box. +-- Tutte le regole valide di una VBox sono combinate in AND. +-- Campi NULL = "non filtrare su questo criterio". +CREATE TABLE virtual_box_rules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + virtual_box_id UUID NOT NULL REFERENCES virtual_boxes(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Filtri strutturali (NULL = qualsiasi) + mailbox_id UUID REFERENCES mailboxes(id) ON DELETE CASCADE, + imap_folder VARCHAR(255), -- es. 'INBOX', 'Sent' + direction pec_direction, -- inbound | outbound | NULL + pec_type pec_msg_type, -- filtra per tipo PEC + + -- Filtri testuali (ILIKE '%pattern%' case-insensitive, NULL = qualsiasi) + subject_pattern TEXT, -- es. 'Multa' + from_pattern TEXT, -- es. 'comune.roma.it' + to_pattern TEXT, -- filtra su to_addresses + + -- Filtro temporale + date_from DATE, -- messaggi ricevuti da questa data + date_to DATE, + + -- Filtro su etichetta + label_id UUID REFERENCES labels(id), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_vbox_rules_vbox ON virtual_box_rules (virtual_box_id); + +-- Assegnazione utenti alle Virtual Box +-- Un utente può essere assegnato a più VBox (in OR tra loro, vede l'unione) +CREATE TABLE virtual_box_assignments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + virtual_box_id UUID NOT NULL REFERENCES virtual_boxes(id) ON DELETE CASCADE, + assigned_by UUID REFERENCES users(id), + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_vbox_assign UNIQUE (user_id, virtual_box_id) +); + +CREATE INDEX idx_vbox_assign_user ON virtual_box_assignments (user_id); +CREATE INDEX idx_vbox_assign_vbox ON virtual_box_assignments (virtual_box_id); + + +-- ============================================================ +-- D. NOTIFICHE +-- ============================================================ + +CREATE TYPE notification_channel_type AS ENUM ('webhook', 'email_smtp', 'telegram', 'whatsapp'); +CREATE TYPE notification_event_type AS ENUM ( + 'message.received', -- nuova PEC ricevuta + 'message.delivered', -- PEC outbound consegnata + 'message.anomaly', -- PEC anomalia (mancata consegna, virus, ecc.) + 'message.failed', -- invio fallito definitivamente + 'mailbox.error', -- casella in stato error + 'archival.completed', -- versamento conservatore completato + 'archival.failed', -- versamento fallito + 'mailbox.quota_warning' -- casella quasi piena (stima da sync) +); + +-- Canali di notifica configurati per tenant (o per singolo utente) +CREATE TABLE notification_channels ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id), -- NULL = canale condiviso del tenant + name VARCHAR(255) NOT NULL, + type notification_channel_type NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Configurazione specifica per tipo (cifrata se contiene segreti) + -- webhook: { "url": "https://...", "secret": "hmac_key" } + -- email_smtp: { "to": ["addr1","addr2"] } + -- telegram: { "bot_token_enc": "...", "chat_id": "123456" } + -- whatsapp: { "phone_number_id": "...", "access_token_enc": "...", "to": "+39..." } + config_enc JSONB NOT NULL, -- AES-256-GCM su campi sensibili + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_notif_ch_tenant ON notification_channels (tenant_id); + +-- Regole: quali eventi triggherano quale canale, con filtri opzionali +CREATE TABLE notification_rules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE, + event_type notification_event_type NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Filtri opzionali (NULL = qualsiasi) + mailbox_id UUID REFERENCES mailboxes(id), -- solo per questa casella + from_pattern TEXT, -- mittente ILIKE '%pattern%' + subject_pattern TEXT, -- oggetto ILIKE '%pattern%' + pec_type pec_msg_type, -- solo questo tipo PEC + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_notif_rule_tenant ON notification_rules (tenant_id); +CREATE INDEX idx_notif_rule_channel ON notification_rules (channel_id); +CREATE INDEX idx_notif_rule_event ON notification_rules (event_type); + +-- Log delle notifiche inviate +CREATE TABLE notification_log ( + id BIGSERIAL PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id), + rule_id UUID REFERENCES notification_rules(id), + channel_id UUID REFERENCES notification_channels(id), + message_id UUID REFERENCES messages(id), -- messaggio che ha triggerato + event_type notification_event_type NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending|sent|failed + attempt_count INT NOT NULL DEFAULT 0, + last_error TEXT, + payload JSONB, -- payload inviato (per debug) + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_notif_log_tenant ON notification_log (tenant_id, created_at DESC); +CREATE INDEX idx_notif_log_status ON notification_log (status) WHERE status = 'pending'; + + +-- ============================================================ +-- Estendi trigger set_updated_at ai nuovi modelli +-- ============================================================ +DO $$ DECLARE t TEXT; BEGIN + FOREACH t IN ARRAY ARRAY['virtual_boxes','notification_channels'] LOOP + EXECUTE format('CREATE TRIGGER trg_%s_updated_at + BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION set_updated_at()', t, t); + END LOOP; +END $$; +``` + +--- + +## 3.B Piano di Sviluppo – Fasi Aggiuntive (Sistemi Avanzati) + +Le seguenti fasi si inseriscono nel piano originale. La **Fase 1-A** è contestuale +alla Fase 1, le altre possono essere sviluppate in parallelo al frontend (Fase 5). + +--- + +### Fase 1-A – Permessi Granulari (inserita in Fase 1, +1 settimana) + +**Obiettivo:** implementare il sistema di permessi a due livelli: ruoli globali + permessi per casella. + +**Task:** +- [ ] Alembic migration per `mailbox_permissions` +- [ ] `permission_service.py`: `check_user_can_read(user, mailbox)`, `check_user_can_send(user, mailbox)` +- [ ] Middleware FastAPI: ogni endpoint messaggi/invio chiama `permission_service` prima di procedere +- [ ] API `POST /permissions/mailboxes/{mailbox_id}/users/{user_id}` (admin assegna permesso) +- [ ] API `DELETE /permissions/mailboxes/{mailbox_id}/users/{user_id}` (admin revoca) +- [ ] API `GET /permissions/mailboxes/{mailbox_id}/users` (lista permessi casella) +- [ ] API `GET /permissions/users/{user_id}/mailboxes` (caselle accessibili a un utente) +- [ ] Test: utente senza permesso riceve 403, utente con `can_read=False` non vede messaggi +- [ ] Frontend: pagina Permissions (admin) con tabella utenti × caselle con toggle + +**Definition of Done:** +- Utente `readonly` senza permessi espliciti non vede nessuna casella +- Admin aggiunge/revoca permesso e l'effetto è immediato (no cache JWT) +- Test di integrazione coprono tutti i casi limite (ruolo vs permesso esplicito) + +--- + +### Fase 5-A – Virtual Box (inserita dopo Fase 5, 2 settimane) + +**Obiettivo:** permettere agli admin di creare viste filtrate e assegnarle a utenti specifici. + +**Task:** +- [ ] Alembic migration per `virtual_boxes`, `virtual_box_rules`, `virtual_box_assignments` +- [ ] `virtual_box_service.py`: `build_filter_clauses(user_id)` → lista di clausole SQLAlchemy +- [ ] Integrazione in `message_service.py`: se l'utente ha VBox assegnate, applica filtri +- [ ] Stessa integrazione in `search_service.py` (ricerca non esce dal perimetro VBox) +- [ ] API `POST /virtual-boxes` (crea VBox con regole) +- [ ] API `PUT /virtual-boxes/{id}/rules` (aggiorna regole) +- [ ] API `POST /virtual-boxes/{id}/assignments` (assegna utente) +- [ ] API `DELETE /virtual-boxes/{id}/assignments/{user_id}` (rimuovi assegnazione) +- [ ] Frontend: pagina VirtualBoxes – builder visuale delle regole (dropdown campi, input pattern) +- [ ] Frontend: Inbox mostra badge "VBox attiva: [nome]" se l'utente è in modalità filtrata +- [ ] Test: utente pbianchi in VBox "Multe info@" non vede mail con oggetto diverso da "Multa" + +**Definition of Done:** +- Scenario di esempio funzionante: admin crea VBox, assegna utente, utente vede solo mail filtrate +- Admin che non ha VBox assegnata vede tutto normalmente + +--- + +### Fase 5-B – Ricerca Avanzata (inserita dopo Fase 5, 2 settimane) + +**Obiettivo:** ricerca full-text su tutti i campi del messaggio e sul contenuto degli allegati. + +**Task:** +- [ ] Alembic migration: colonna `search_vector` GENERATED + `attachments.extracted_text` +- [ ] `worker/search/text_extractor.py`: chiama Apache Tika (`docker run apache/tika`) via HTTP REST, estrae testo da PDF/DOCX/ODT/TXT/HTML +- [ ] Job `index_message`: avviato dopo ogni sync IMAP, estrae testo allegati e aggiorna vettore +- [ ] `search_service.py`: costruisce `tsquery` da input utente (parole AND, frasi con `""`, NOT con `-`) +- [ ] API `POST /search`: accetta `{ q, mailbox_ids, direction, date_from, date_to, has_attachments, labels, pec_type }` +- [ ] Risposta: `{ results: [...], total, highlights: { message_id: {field: snippet} } }` +- [ ] Frontend: pagina Search con barra + pannello filtri laterale collapsible +- [ ] Hook `useSearch`: debounce 400ms, history ultimi 10 termini (localStorage), highlight parole chiave nei risultati +- [ ] Test: ricerca "Multa" trova messaggi con "Multa" in oggetto, corpo E allegato PDF contenente "Multa" + +**Definition of Done:** +- Ricerca restituisce risultati in < 500ms su dataset di 100.000 messaggi +- Evidenziazione (highlight) visibile nel frontend +- I filtri VBox si applicano anche alla ricerca + +--- + +### Fase 5-C – Sistema Notifiche (in parallelo con 5-A e 5-B, 3 settimane) + +**Obiettivo:** notifiche granulari su eventi PEC via webhook, email, Telegram e WhatsApp. + +**Task:** +- [ ] Alembic migration per `notification_channels`, `notification_rules`, `notification_log` +- [ ] API `POST /notifications/channels` (crea canale, config cifrata a riposo) +- [ ] API `POST /notifications/channels/{id}/test` (invia notifica di test) +- [ ] API `POST /notifications/rules` (crea regola evento → canale + filtri) +- [ ] `notification_service.py`: al salvataggio di ogni messaggio, valuta regole applicabili e accoda job +- [ ] `worker/notifications/dispatcher.py`: smista per tipo canale +- [ ] `webhook.py`: POST JSON con header `X-PecFlow-Signature: sha256=` per verifica autenticità +- [ ] `email_smtp.py`: template HTML notifica (oggetto, mittente, link messaggio) +- [ ] `telegram.py`: messaggio Telegram con MarkdownV2, link deep al messaggio +- [ ] `whatsapp.py`: Meta Cloud API `POST /messages` con template pre-approvato (o freeform in 24h window) +- [ ] Retry: 3 tentativi per canale, log in `notification_log` +- [ ] Frontend: pagina Notifications – gestione canali (form per tipo) + lista regole +- [ ] Test integrazione: mock HTTP server per webhook, verifica HMAC; mock Telegram API + +**Definition of Done:** +- Demo completa: nuovo messaggio PEC → webhook riceve POST in < 5 secondi +- Telegram e WhatsApp testati con account sandbox +- Log notifiche visibile in frontend (admin) + +--- + +## 5. Sistemi Avanzati + +--- + +### 5.1 Permessi Utente Granulari + +#### Gerarchia ruoli + +| Ruolo | Accesso caselle | Invio | Configurazione | Gestione utenti | +|---|---|---|---|---| +| `super_admin` | Tutti i tenant | ✓ | ✓ | ✓ | +| `admin` | Tutte le caselle del tenant | ✓ | ✓ | ✓ | +| `supervisor` | Caselle con permesso esplicito | ✓ | ✗ | ✗ | +| `operator` | Caselle con `can_read=TRUE` | Solo se `can_send=TRUE` | ✗ | ✗ | +| `readonly` | Caselle con `can_read=TRUE` | ✗ | ✗ | ✗ | + +#### Logica applicata nel backend + +```python +# permission_service.py + +def get_visible_mailboxes(user: User, db: Session) -> list[UUID]: + """Restituisce le caselle visibili all'utente.""" + if user.role in ('super_admin', 'admin'): + return db.query(Mailbox.id).filter(Mailbox.tenant_id == user.tenant_id).all() + return ( + db.query(MailboxPermission.mailbox_id) + .filter( + MailboxPermission.user_id == user.id, + MailboxPermission.can_read == True, + ) + .all() + ) + +def check_can_send(user: User, mailbox_id: UUID, db: Session) -> bool: + if user.role in ('super_admin', 'admin'): + return True + perm = db.query(MailboxPermission).filter_by( + user_id=user.id, mailbox_id=mailbox_id + ).first() + return perm is not None and perm.can_send +``` + +#### Esempi di utilizzo reale + +- **Studio legale**: paralegale `plegal` può leggere e inviare da `pec@studiorossi.it`, non da `amministrazione@studiorossi.it` +- **PA**: funzionario `fmarco` ha `can_read=TRUE`, `can_send=FALSE` → può leggere ma non rispondere +- **Call center**: tutti gli operatori hanno `can_read` su `info@azienda.it`, solo il team leader ha `can_send` + +--- + +### 5.2 Virtual Box + +#### Concetto + +Una **Virtual Box** è una vista nominata e filtrata su un sottoinsieme di messaggi. Quando un utente ha una o più VBox assegnate, l'applicazione applica automaticamente i filtri a *tutte* le sue query messaggi, inclusa la ricerca avanzata. + +Un utente può avere **più VBox** assegnate → vede l'**unione** (OR) dei messaggi che soddisfano almeno una VBox. Le regole *dentro* una singola VBox sono in **AND** tra loro. + +#### Esempio concreto + +**Scenario:** Comune di Esempio ha la casella `protocollo@comune.it`. Vuole che: +- L'operatore `pbianchi` veda solo le PEC con oggetto contenente "Multa" da `protocollo@comune.it` +- L'operatore `gverdi` veda solo le PEC da mittenti `@prefettura.it` + +``` +VBox "Multe - Protocollo" + └── Regole: + mailbox_id = + subject_pattern = 'Multa' + +VBox "Prefettura - Protocollo" + └── Regole: + mailbox_id = + from_pattern = 'prefettura.it' + +Assegnazioni: + pbianchi → "Multe - Protocollo" + gverdi → "Prefettura - Protocollo" +``` + +#### Implementazione SQL query + +```sql +-- Query messaggi per utente con VBox attiva +-- Generata da virtual_box_service.py + +SELECT m.* +FROM messages m +WHERE m.tenant_id = :tenant_id + AND m.mailbox_id IN ( + -- solo caselle con permesso can_read + SELECT mailbox_id FROM mailbox_permissions + WHERE user_id = :user_id AND can_read = TRUE + ) + AND ( + -- OR tra le VBox assegnate + (m.mailbox_id = :vbox1_mailbox AND m.subject ILIKE '%Multa%') + OR + (m.mailbox_id = :vbox2_mailbox AND m.from_address ILIKE '%prefettura.it%') + ) +ORDER BY m.received_at DESC; +``` + +#### API Reference + +| Metodo | Endpoint | Descrizione | +|---|---|---| +| `POST` | `/virtual-boxes` | Crea VBox con regole iniziali | +| `GET` | `/virtual-boxes` | Lista VBox del tenant | +| `PUT` | `/virtual-boxes/{id}` | Aggiorna nome/descrizione | +| `PUT` | `/virtual-boxes/{id}/rules` | Sostituisce tutte le regole | +| `POST` | `/virtual-boxes/{id}/assignments` | Assegna utente | +| `DELETE` | `/virtual-boxes/{id}/assignments/{user_id}` | Rimuovi assegnazione | +| `GET` | `/virtual-boxes/my` | VBox dell'utente corrente | + +--- + +### 5.3 Ricerca Avanzata + +#### Campi ricercabili + +| Campo | Tipo ricerca | Peso FTS | Note | +|---|---|---|---| +| `subject` | FTS + ILIKE | A (massimo) | | +| `from_address` | FTS + ILIKE | B | | +| `to_addresses` | FTS array | B | | +| `body_text` | FTS | C | | +| allegati `.extracted_text` | FTS | D (minimo) | dopo estrazione Tika | +| `received_at` | range date | — | filtro, non FTS | +| `state` | exact | — | filtro | +| `direction` | exact | — | filtro | +| `mailbox_id` | exact | — | filtro | +| label | join | — | filtro | + +#### Sintassi query utente + +``` +multa comune → entrambe le parole (AND implicito) +"avvenuta consegna" → frase esatta +multa -cartella → multa ma NOT cartella +comune OR prefettura → OR esplicito +``` + +Il `search_service.py` traduce la query utente in `tsquery` PostgreSQL: +```python +# "multa comune" → to_tsquery('italian', 'multa & comune') +# '"avvenuta consegna"' → phraseto_tsquery('italian', 'avvenuta consegna') +``` + +#### API + +``` +POST /search +{ + "q": "multa comune", + "mailbox_ids": ["uuid1", "uuid2"], // opzionale + "direction": "inbound", // opzionale + "date_from": "2026-01-01", // opzionale + "date_to": "2026-12-31", // opzionale + "has_attachments": true, // opzionale + "labels": ["uuid_label"], // opzionale + "pec_type": "posta_certificata", // opzionale + "page": 1, + "page_size": 25 +} + +→ 200 OK +{ + "total": 142, + "results": [ + { + "id": "...", + "subject": "Multa n. 12345/2026 del Comune di Roma", + "from_address": "protocollo@comune.roma.it", + "received_at": "2026-03-15T10:30:00Z", + "highlights": { + "subject": "...del Comune di Roma", + "body_text": "...in merito alla multa stradale..." + } + } + ] +} +``` + +#### Estrazione testo allegati + +Il worker `text_extractor.py` chiama Apache Tika REST API: + +```python +import httpx + +async def extract_text(file_bytes: bytes, content_type: str) -> str | None: + async with httpx.AsyncClient() as client: + r = await client.put( + "http://tika:9998/tika", + content=file_bytes, + headers={"Content-Type": content_type, "Accept": "text/plain"}, + timeout=30.0, + ) + return r.text if r.status_code == 200 else None +``` + +Apache Tika è incluso nel `docker-compose.yml` come container `tika:latest` (porta 9998). Supporta PDF, DOCX, ODT, XLSX, HTML, XML, TXT e altri 1000+ formati. + +--- + +### 5.4 Sistema di Notifiche Multi-canale + +#### Architettura + +``` +Evento PEC (trigger: salvataggio nuovo messaggio o cambio stato) + │ + ├── notification_service.evaluate_rules(event_type, message) + │ ├── filtra regole per event_type + │ ├── applica filtri (mailbox, from_pattern, subject_pattern) + │ └── per ogni regola corrispondente: + │ → accoda job dispatch_notification(rule_id, message_id) + │ + └── [arq job queue Redis] + │ + └── dispatcher.py + ├── carica canale + configurazione + └── chiama channel handler specifico: + ├── webhook.py + ├── email_smtp.py + ├── telegram.py + └── whatsapp.py +``` + +#### Canali supportati + +**Webhook** +```json +POST https://your-endpoint.com/pecflow-hook +Headers: + X-PecFlow-Event: message.received + X-PecFlow-Signature: sha256= + Content-Type: application/json + +Body: +{ + "event": "message.received", + "tenant_id": "...", + "message": { + "id": "...", + "subject": "PEC ricevuta da comune.roma.it", + "from_address": "protocollo@comune.roma.it", + "mailbox": "info@azienda.it", + "received_at": "2026-03-18T14:00:00Z", + "state": "received", + "url": "https://app.pecflow.it/messages/..." + } +} +``` + +**Email SMTP** +Notifica in HTML via template configurabile. Il mittente usa un relay SMTP dedicato (non la casella PEC del cliente). Supporta più destinatari nella config del canale. + +**Telegram** +``` +🔔 Nuova PEC ricevuta + +📮 Casella: info@azienda.it +👤 Da: protocollo@comune.roma.it +📋 Oggetto: Convocazione riunione del 20/03/2026 +🕐 Ricevuta: 18/03/2026 14:00 + +🔗 Visualizza: https://app.pecflow.it/messages/... +``` + +**WhatsApp (Meta Cloud API)** +Template pre-approvato Meta per notifiche al di fuori della finestra 24h. Per messaggi dentro la finestra 24h (es. risposta a notifica) si usa freeform. La configurazione del canale include `phone_number_id`, `access_token` (cifrato) e numero destinatario. + +#### Configurazione canale via API + +```json +POST /notifications/channels +{ + "name": "Webhook ERP Aziendale", + "type": "webhook", + "config": { + "url": "https://erp.azienda.it/api/pec-webhook", + "secret": "mysecretkey123" + } +} + +POST /notifications/channels +{ + "name": "Alert Telegram Team", + "type": "telegram", + "config": { + "bot_token": "123456:ABC...", + "chat_id": "-1001234567890" + } +} + +POST /notifications/rules +{ + "channel_id": "uuid-del-canale", + "event_type": "message.received", + "mailbox_id": "uuid-casella-info", // opzionale + "from_pattern": "comune.roma.it" // opzionale +} +``` + +#### Test canale + +``` +POST /notifications/channels/{id}/test +→ invia una notifica di test con dati fittizi al canale +→ risponde con: { "status": "sent" | "failed", "error": "..." } +``` + +#### Gestione errori e retry + +| Scenario | Comportamento | +|---|---| +| Endpoint webhook irraggiungibile | Retry 3x (5 min → 30 min → 2h), poi `failed` in `notification_log` | +| Telegram bot bloccato dall'utente | `failed` immediato, no retry, alert admin | +| WhatsApp fuori finestra 24h senza template | Usa template approvato o salta con log warning | +| Email SMTP rifiutata | Retry 3x, poi `failed` | +| Tutti i canali di una regola falliti | Alert WebSocket all'admin del tenant | diff --git a/KnowledgeBaseCline.md b/KnowledgeBaseCline.md new file mode 100644 index 0000000..63b294c --- /dev/null +++ b/KnowledgeBaseCline.md @@ -0,0 +1,43 @@ +Stiamo lavorando a un PEC Manager SaaS + +Effettua tutti i test in locale + +Ho docker installato, compose v2 (docker cmpose senza trattino) + +Queste le caselle PEC e i loro parametri IMAP/SMTP che puoi usare per test, non effettuare invii per adesso + +Casella: gmgspa@pec.it +Password: Gmgbaccio1960!26 +Server IMAP: imaps.pec.mail-certificata.eu +Porta:993 +SSL: Sì +Server SMTP: smtps.pec.mail-certificata.eu +Porta: 465 +SSL: Sì + +Casella: akron@pec.akronservices.it +Password: Akron@PEC2026! +Server IMAP: imap.pec-email.com +Porta:993 +SSL: Sì +Server SMTP: smtp.pec-email.com +Porta: 465 +SSL: Sì + +Casella: birindelliauto@pec.it +Password: PECBirindelli2026@ +Server IMAP: imaps.pec.mail-certificata.eu +Porta:993 +SSL: Sì +Server SMTP: smtps.pec.mail-certificata.eu +Porta: 465 +SSL: Sì + +Casella: quattrocarsrl@pec.it +Password: 4Car2026GMG! +Server IMAP: imaps.pec.mail-certificata.eu +Porta:993 +SSL: Sì +Server SMTP: smtps.pec.mail-certificata.eu +Porta: 465 +SSL: Sì \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fde66df --- /dev/null +++ b/Makefile @@ -0,0 +1,106 @@ +## PecFlow – Developer Commands + +.PHONY: dev down build test migrate seed lint format clean logs ps help + +# Variabili +COMPOSE = docker compose +BACKEND = $(COMPOSE) exec backend +PYTEST = $(BACKEND) python -m pytest + +help: ## Mostra questo help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# ─── Stack locale ──────────────────────────────────────────────────────────── + +dev: ## Avvia l'intero stack in background + $(COMPOSE) up -d --build + @echo "" + @echo " ✅ Stack avviato:" + @echo " 📡 API: http://localhost:8000" + @echo " 📖 Docs: http://localhost:8000/docs" + @echo " 🗄️ MinIO: http://localhost:9001 (admin/password)" + @echo " 📊 PgAdmin: http://localhost:5050 (admin@pecflow.it / admin)" + @echo "" + +down: ## Ferma e rimuove i container (preserva i volumi) + $(COMPOSE) down + +down-v: ## Ferma e rimuove TUTTO inclusi i volumi (reset completo) + $(COMPOSE) down -v + +build: ## Rebuilda le immagini senza usare la cache + $(COMPOSE) build --no-cache + +logs: ## Segui i log di tutti i servizi + $(COMPOSE) logs -f + +logs-backend: ## Segui i log del backend + $(COMPOSE) logs -f backend + +ps: ## Stato dei container + $(COMPOSE) ps + +# ─── Database ──────────────────────────────────────────────────────────────── + +migrate: ## Esegui le migrazioni Alembic pendenti + $(BACKEND) alembic upgrade head + +migrate-down: ## Rollback dell'ultima migrazione + $(BACKEND) alembic downgrade -1 + +migrate-status: ## Stato migrazioni + $(BACKEND) alembic current + +makemigration: ## Genera una nuova migrazione (usa: make makemigration MSG="descrizione") + $(BACKEND) alembic revision --autogenerate -m "$(MSG)" + +seed: ## Esegui seed dati di sviluppo (tenant demo + admin) + $(COMPOSE) exec db psql -U pecflow -d pecflow -f /docker-entrypoint-initdb.d/seeds/dev_tenant.sql + @echo " ✅ Seed completato" + +reset-db: ## Reset completo DB (down-v + dev + migrate + seed) + $(MAKE) down-v + $(MAKE) dev + @sleep 5 + $(MAKE) migrate + $(MAKE) seed + +# ─── Test ──────────────────────────────────────────────────────────────────── + +test: ## Esegui tutti i test (unit + integration) + $(PYTEST) -v --tb=short + +test-unit: ## Solo unit test + $(PYTEST) backend/tests/unit -v + +test-integration: ## Solo integration test + $(PYTEST) backend/tests/integration -v + +test-cov: ## Test con coverage report + $(PYTEST) --cov=app --cov-report=term-missing --cov-report=html:/app/htmlcov -v + +# ─── Code quality ───────────────────────────────────────────────────────────── + +lint: ## Esegui linting (ruff + mypy) + $(BACKEND) ruff check app tests + $(BACKEND) mypy app --ignore-missing-imports + +format: ## Formatta il codice con ruff + $(BACKEND) ruff format app tests + $(BACKEND) ruff check --fix app tests + +# ─── Utility ───────────────────────────────────────────────────────────────── + +shell-backend: ## Shell nel container backend + $(BACKEND) bash + +shell-db: ## psql nel container database + $(COMPOSE) exec db psql -U pecflow -d pecflow + +clean: ## Rimuovi file temporanei Python + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -name "*.pyc" -delete 2>/dev/null || true + find . -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + find . -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true + find . -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..036dd8c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +# Dipendenze di sistema +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Aggiorna pip e setuptools prima di tutto +RUN pip install --no-cache-dir --upgrade pip setuptools wheel + +# Copia pyproject.toml e installa dipendenze (layer cache separato dal codice) +COPY pyproject.toml . +# Crea struttura minima per permettere l'installazione +RUN mkdir -p app && touch app/__init__.py +RUN pip install --no-cache-dir -e ".[dev]" + +# Copia il codice sorgente (sovrascrive app/__init__.py vuoto) +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..cb9df78 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,55 @@ +# Alembic – configurazione migrazioni database + +[alembic] +# Percorso directory migrazioni +script_location = alembic + +# Template per nuovi file migrazione +file_template = %%(rev)s_%%(slug)s + +# Timezone per timestamp nelle revisioni +timezone = UTC + +# Opzioni connessione (override da env.py) +# sqlalchemy.url = postgresql://... + +[post_write_hooks] +# ruff format sui file migrazione generati +hooks = ruff +ruff.type = exec +ruff.executable = %(here)s/.venv/bin/ruff +ruff.options = format REVISION_SCRIPT_FILENAME + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/__init__.py b/backend/alembic/__init__.py new file mode 100644 index 0000000..f6a8bff --- /dev/null +++ b/backend/alembic/__init__.py @@ -0,0 +1 @@ +# Alembic migrations diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..76c7116 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,76 @@ +""" +Alembic env.py – configurazione migrazioni per PostgreSQL. +Usa il driver SYNC (psycopg2) che è più semplice e compatibile con Alembic. +""" + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.config import get_settings +from app.database import Base + +# Importa tutti i modelli per permettere l'autogenerazione +import app.models # noqa: F401 + +settings = get_settings() + +# Configurazione Alembic +config = context.config + +# Configura logging da alembic.ini +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Metadata per autogenerazione +target_metadata = Base.metadata + +# Usa DATABASE_URL_SYNC (psycopg2) per le migrazioni +config.set_main_option("sqlalchemy.url", settings.database_url_sync) + + +def run_migrations_offline() -> None: + """Esegui migrazioni in modalità 'offline' (genera SQL senza connettersi).""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Esegui migrazioni in modalità 'online' con connessione DB diretta.""" + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/versions/0001_initial_schema.py b/backend/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..1c75709 --- /dev/null +++ b/backend/alembic/versions/0001_initial_schema.py @@ -0,0 +1,358 @@ +"""Initial schema – tutte le tabelle PecFlow Fase 1 + +Revision ID: 0001 +Revises: +Create Date: 2026-03-18 00:00:00.000000 +""" + +from alembic import op + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Esegui l'intero schema come SQL puro (più affidabile con ENUM types) + op.execute(""" + -- ── Estensioni ─────────────────────────────────────────────────────── + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + """) + + # ── ENUM types ──────────────────────────────────────────────────────────── + op.execute("CREATE TYPE user_role AS ENUM ('super_admin','admin','supervisor','operator','readonly')") + op.execute("CREATE TYPE mailbox_status AS ENUM ('active','paused','error','deleted')") + op.execute("CREATE TYPE pec_direction AS ENUM ('inbound','outbound')") + op.execute("CREATE TYPE pec_state AS ENUM ('draft','queued','sent','accepted','delivered','anomaly','failed','received')") + op.execute("CREATE TYPE pec_msg_type AS ENUM ('posta_certificata','accettazione','non_accettazione','presa_in_carico','avvenuta_consegna','mancata_consegna','errore_consegna','preavviso_mancata_consegna','rilevazione_virus','unknown')") + op.execute("CREATE TYPE send_job_status AS ENUM ('pending','sending','sent','failed','retrying')") + op.execute("CREATE TYPE archival_status AS ENUM ('pending','building_sip','uploading','uploaded','confirmed','rejected','failed')") + + # ── 1. TENANTS ──────────────────────────────────────────────────────────── + op.execute(""" + CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + slug VARCHAR(63) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + plan VARCHAR(50) NOT NULL DEFAULT 'starter', + 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() + ) + """) + op.execute("CREATE INDEX idx_tenants_slug ON tenants (slug)") + + # ── 2. USERS ────────────────────────────────────────────────────────────── + op.execute(""" + 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, + full_name VARCHAR(255) NOT NULL, + role user_role NOT NULL DEFAULT 'operator', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + totp_secret TEXT, + 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) + ) + """) + op.execute("CREATE INDEX idx_users_tenant ON users (tenant_id)") + op.execute("CREATE INDEX idx_users_email ON users (email)") + op.execute("ALTER TABLE users ENABLE ROW LEVEL SECURITY") + op.execute(""" + CREATE POLICY users_tenant_isolation ON users + USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID) + """) + + # ── 3. REFRESH TOKENS ───────────────────────────────────────────────────── + op.execute(""" + 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, + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + user_agent TEXT, + ip_address INET + ) + """) + op.execute("CREATE INDEX idx_rt_user ON refresh_tokens (user_id)") + op.execute("CREATE INDEX idx_rt_expires ON refresh_tokens (expires_at) WHERE revoked_at IS NULL") + + # ── 4. MAILBOXES ────────────────────────────────────────────────────────── + op.execute(""" + 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), + 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, + 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) + ) + """) + op.execute("CREATE INDEX idx_mailboxes_tenant ON mailboxes (tenant_id)") + op.execute("CREATE INDEX idx_mailboxes_status ON mailboxes (status) WHERE status = 'active'") + op.execute("ALTER TABLE mailboxes ENABLE ROW LEVEL SECURITY") + op.execute(""" + CREATE POLICY mailboxes_tenant_isolation ON mailboxes + USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID) + """) + + # ── 5. MESSAGES ─────────────────────────────────────────────────────────── + op.execute(""" + 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, + message_id_header TEXT, + imap_uid BIGINT, + 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, + subject TEXT, + from_address VARCHAR(255), + to_addresses TEXT[], + cc_addresses TEXT[], + sent_at TIMESTAMPTZ, + received_at TIMESTAMPTZ, + size_bytes BIGINT, + body_text TEXT, + body_html TEXT, + has_attachments BOOLEAN NOT NULL DEFAULT FALSE, + parent_message_id UUID REFERENCES messages(id), + 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_eml_path TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX idx_messages_tenant ON messages (tenant_id)") + op.execute("CREATE INDEX idx_messages_mailbox ON messages (mailbox_id)") + op.execute("CREATE INDEX idx_messages_state ON messages (state)") + op.execute("CREATE INDEX idx_messages_received_at ON messages (received_at DESC)") + op.execute("CREATE INDEX idx_messages_imap_uid ON messages (mailbox_id, imap_uid)") + op.execute("CREATE INDEX idx_messages_parent ON messages (parent_message_id) WHERE parent_message_id IS NOT NULL") + op.execute("CREATE INDEX idx_messages_subject_fts ON messages USING GIN (to_tsvector('italian', COALESCE(subject, '')))") + op.execute("ALTER TABLE messages ENABLE ROW LEVEL SECURITY") + op.execute(""" + CREATE POLICY messages_tenant_isolation ON messages + USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID) + """) + + # ── 6. ATTACHMENTS ──────────────────────────────────────────────────────── + op.execute(""" + 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, + checksum_sha256 CHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX idx_attachments_message ON attachments (message_id)") + + # ── 7. SEND_JOBS ────────────────────────────────────────────────────────── + op.execute(""" + 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), + 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) + ) + """) + op.execute("CREATE INDEX idx_sendjobs_tenant ON send_jobs (tenant_id)") + op.execute("CREATE INDEX idx_sendjobs_status ON send_jobs (status, next_retry_at) WHERE status IN ('pending','retrying')") + + # ── 8. ARCHIVAL ─────────────────────────────────────────────────────────── + op.execute(""" + 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, + status archival_status NOT NULL DEFAULT 'pending', + sip_path TEXT, + sip_checksum CHAR(64), + versamento_id TEXT, + rdv_received_at TIMESTAMPTZ, + rdv_path TEXT, + 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, + period_to DATE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX idx_archival_tenant ON archival_batches (tenant_id)") + op.execute("CREATE INDEX idx_archival_status ON archival_batches (status, next_retry_at)") + + op.execute(""" + 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) + ) + """) + + op.execute(""" + 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 ────────────────────────────────────────────────────────── + op.execute(""" + 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, + resource_type VARCHAR(100), + resource_id UUID, + ip_address INET, + user_agent TEXT, + payload JSONB, + outcome VARCHAR(20) NOT NULL DEFAULT 'success', + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX idx_audit_tenant_date ON audit_log (tenant_id, occurred_at DESC)") + op.execute("CREATE INDEX idx_audit_user ON audit_log (user_id, occurred_at DESC)") + op.execute("CREATE INDEX idx_audit_action ON audit_log (action)") + op.execute("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY") + op.execute("CREATE POLICY audit_no_delete ON audit_log FOR DELETE USING (FALSE)") + op.execute("CREATE POLICY audit_no_update ON audit_log FOR UPDATE USING (FALSE)") + op.execute(""" + CREATE POLICY audit_tenant_read ON audit_log FOR SELECT + USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID) + """) + + # ── 10. LABELS ──────────────────────────────────────────────────────────── + op.execute(""" + 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), + CONSTRAINT uq_label_name_tenant UNIQUE (tenant_id, name) + ) + """) + + op.execute(""" + 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) + ) + """) + + # ── 11. MAILBOX_PERMISSIONS (Fase 1-A) ──────────────────────────────────── + op.execute(""" + 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, + can_send BOOLEAN NOT NULL DEFAULT FALSE, + can_manage BOOLEAN NOT NULL DEFAULT FALSE, + granted_by UUID REFERENCES users(id), + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_perm_user_mailbox UNIQUE (user_id, mailbox_id) + ) + """) + op.execute("CREATE INDEX idx_mbperm_user ON mailbox_permissions (user_id)") + op.execute("CREATE INDEX idx_mbperm_mailbox ON mailbox_permissions (mailbox_id)") + op.execute("CREATE INDEX idx_mbperm_tenant ON mailbox_permissions (tenant_id)") + + # ── Trigger updated_at ──────────────────────────────────────────────────── + op.execute(""" + CREATE OR REPLACE FUNCTION set_updated_at() + RETURNS TRIGGER LANGUAGE plpgsql AS $$ + BEGIN NEW.updated_at = NOW(); RETURN NEW; END; + $$ + """) + for table in ["tenants", "users", "mailboxes", "messages", "archival_batches"]: + op.execute(f""" + CREATE TRIGGER trg_{table}_updated_at + BEFORE UPDATE ON {table} + FOR EACH ROW EXECUTE FUNCTION set_updated_at() + """) + + +def downgrade() -> None: + # Rimuovi trigger + for table in ["tenants", "users", "mailboxes", "messages", "archival_batches"]: + op.execute(f"DROP TRIGGER IF EXISTS trg_{table}_updated_at ON {table}") + op.execute("DROP FUNCTION IF EXISTS set_updated_at()") + + # Rimuovi tabelle (ordine inverso per FK) + for table in [ + "mailbox_permissions", "message_labels", "labels", + "audit_log", "archival_dips", "archival_batch_messages", + "archival_batches", "send_jobs", "attachments", "messages", + "mailboxes", "refresh_tokens", "users", "tenants", + ]: + op.execute(f"DROP TABLE IF EXISTS {table} CASCADE") + + # Rimuovi enum types + for enum_name in [ + "archival_status", "send_job_status", "pec_msg_type", + "pec_state", "pec_direction", "mailbox_status", "user_role", + ]: + op.execute(f"DROP TYPE IF EXISTS {enum_name}") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..0aa560e --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# PecFlow Backend diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..df5374a --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API routers diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..1350bad --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1 @@ +# API v1 routers diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..89815c1 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,173 @@ +""" +Router autenticazione – login, refresh, logout, 2FA TOTP. + +Endpoint: + POST /api/v1/auth/login → access + refresh token + POST /api/v1/auth/refresh → rinnova token + POST /api/v1/auth/logout → revoca refresh token + GET /api/v1/auth/me → utente corrente + POST /api/v1/auth/totp/setup → genera segreto TOTP + QR + POST /api/v1/auth/totp/verify → verifica e attiva TOTP + POST /api/v1/auth/totp/disable → disabilita TOTP + POST /api/v1/auth/change-password → cambio password +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from slowapi import Limiter +from slowapi.util import get_remote_address +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.core.exceptions import InvalidCredentialsError +from app.database import get_db +from app.dependencies import CurrentUser, DB +from app.schemas.auth import ( + LoginRequest, + PasswordChangeRequest, + RefreshRequest, + TOTPSetupResponse, + TOTPStatusResponse, + TOTPVerifyRequest, + TokenResponse, +) +from app.schemas.user import UserResponse +from app.services.auth_service import AuthService + +settings = get_settings() +router = APIRouter(prefix="/auth", tags=["Autenticazione"]) +limiter = Limiter(key_func=get_remote_address) + + +@router.post( + "/login", + response_model=TokenResponse, + summary="Login con email e password", + description="Autentica l'utente. Se 2FA è attivo, richiede anche il codice TOTP.", +) +async def login( + request: Request, + body: LoginRequest, + db: DB, +) -> TokenResponse: + ip = request.client.host if request.client else None + ua = request.headers.get("user-agent") + + service = AuthService(db) + access_token, refresh_token = await service.login( + email=body.email, + password=body.password, + totp_code=body.totp_code, + ip_address=ip, + user_agent=ua, + ) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=settings.access_token_expire_minutes * 60, + ) + + +@router.post( + "/refresh", + response_model=TokenResponse, + summary="Rinnova access token", +) +async def refresh_tokens( + body: RefreshRequest, + db: DB, +) -> TokenResponse: + service = AuthService(db) + access_token, refresh_token = await service.refresh_tokens(body.refresh_token) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=settings.access_token_expire_minutes * 60, + ) + + +@router.post( + "/logout", + status_code=204, + summary="Logout – revoca refresh token", +) +async def logout( + body: RefreshRequest, + db: DB, +) -> None: + service = AuthService(db) + await service.logout(body.refresh_token) + + +@router.get( + "/me", + response_model=UserResponse, + summary="Utente corrente autenticato", +) +async def me(current_user: CurrentUser) -> UserResponse: + return UserResponse.model_validate(current_user) + + +@router.post( + "/totp/setup", + response_model=TOTPSetupResponse, + summary="Avvia setup 2FA TOTP", + description="Genera segreto TOTP e QR code. Il 2FA viene attivato solo dopo la verifica.", +) +async def totp_setup( + current_user: CurrentUser, + db: DB, +) -> TOTPSetupResponse: + service = AuthService(db) + data = await service.setup_totp(current_user) + return TOTPSetupResponse(**data) + + +@router.post( + "/totp/verify", + response_model=TOTPStatusResponse, + summary="Verifica codice TOTP e attiva 2FA", +) +async def totp_verify( + body: TOTPVerifyRequest, + current_user: CurrentUser, + db: DB, +) -> TOTPStatusResponse: + service = AuthService(db) + await service.verify_and_enable_totp(current_user, body.totp_code) + return TOTPStatusResponse(totp_enabled=True) + + +@router.post( + "/totp/disable", + response_model=TOTPStatusResponse, + summary="Disabilita 2FA TOTP", +) +async def totp_disable( + current_user: CurrentUser, + db: DB, +) -> TOTPStatusResponse: + service = AuthService(db) + await service.disable_totp(current_user) + return TOTPStatusResponse(totp_enabled=False) + + +@router.post( + "/change-password", + status_code=204, + summary="Cambio password utente corrente", +) +async def change_password( + body: PasswordChangeRequest, + current_user: CurrentUser, + db: DB, +) -> None: + from app.core.security import verify_password, hash_password + + if not verify_password(body.current_password, current_user.password_hash): + raise InvalidCredentialsError() + + current_user.password_hash = hash_password(body.new_password) diff --git a/backend/app/api/v1/permissions.py b/backend/app/api/v1/permissions.py new file mode 100644 index 0000000..5d753d7 --- /dev/null +++ b/backend/app/api/v1/permissions.py @@ -0,0 +1,112 @@ +""" +Router permessi granulari casella (Fase 1-A). + +Endpoint: + POST /api/v1/permissions/mailboxes/{mailbox_id}/users/{user_id} → assegna permesso + DELETE /api/v1/permissions/mailboxes/{mailbox_id}/users/{user_id} → revoca permesso + GET /api/v1/permissions/mailboxes/{mailbox_id}/users → utenti con accesso + GET /api/v1/permissions/users/{user_id}/mailboxes → caselle accessibili +""" + +import uuid + +from fastapi import APIRouter + +from app.dependencies import AdminUser, CurrentUser, DB +from app.schemas.permission import ( + MailboxUserPermissionResponse, + PermissionGrantRequest, + PermissionResponse, + UserMailboxPermissionResponse, +) +from app.services.permission_service import PermissionService + +router = APIRouter(prefix="/permissions", tags=["Permessi casella"]) + + +@router.post( + "/mailboxes/{mailbox_id}/users/{user_id}", + response_model=PermissionResponse, + status_code=201, + summary="Assegna permesso utente su casella", + description="Crea o aggiorna i permessi di un utente su una specifica casella PEC.", +) +async def grant_permission( + mailbox_id: uuid.UUID, + user_id: uuid.UUID, + body: PermissionGrantRequest, + current_user: AdminUser, + db: DB, +) -> PermissionResponse: + service = PermissionService(db) + perm = await service.grant_permission( + tenant_id=current_user.tenant_id, + mailbox_id=mailbox_id, + user_id=user_id, + data=body, + granted_by=current_user, + ) + return PermissionResponse.model_validate(perm) + + +@router.delete( + "/mailboxes/{mailbox_id}/users/{user_id}", + status_code=204, + summary="Revoca permesso utente su casella", +) +async def revoke_permission( + mailbox_id: uuid.UUID, + user_id: uuid.UUID, + current_user: AdminUser, + db: DB, +) -> None: + service = PermissionService(db) + await service.revoke_permission( + mailbox_id=mailbox_id, + user_id=user_id, + revoked_by=current_user, + ) + + +@router.get( + "/mailboxes/{mailbox_id}/users", + response_model=list[MailboxUserPermissionResponse], + summary="Utenti con accesso a una casella", +) +async def list_mailbox_users( + mailbox_id: uuid.UUID, + current_user: AdminUser, + db: DB, +) -> list[MailboxUserPermissionResponse]: + service = PermissionService(db) + rows = await service.list_mailbox_users(mailbox_id, current_user.tenant_id) + return [MailboxUserPermissionResponse(**row) for row in rows] + + +@router.get( + "/users/{user_id}/mailboxes", + response_model=list[UserMailboxPermissionResponse], + summary="Caselle accessibili a un utente", +) +async def list_user_mailboxes( + user_id: uuid.UUID, + current_user: AdminUser, + db: DB, +) -> list[UserMailboxPermissionResponse]: + service = PermissionService(db) + rows = await service.list_user_mailboxes(user_id, current_user.tenant_id) + return [UserMailboxPermissionResponse(**row) for row in rows] + + +@router.get( + "/my/mailboxes", + response_model=list[UserMailboxPermissionResponse], + summary="Caselle accessibili all'utente corrente", +) +async def my_mailboxes( + current_user: CurrentUser, + db: DB, +) -> list[UserMailboxPermissionResponse]: + service = PermissionService(db) + rows = await service.list_user_mailboxes(current_user.id, current_user.tenant_id) + return [UserMailboxPermissionResponse(**row) for row in rows] diff --git a/backend/app/api/v1/tenants.py b/backend/app/api/v1/tenants.py new file mode 100644 index 0000000..8a4cfb1 --- /dev/null +++ b/backend/app/api/v1/tenants.py @@ -0,0 +1,80 @@ +""" +Router tenant – gestione organizzazioni (solo super_admin). + +Endpoint: + GET /api/v1/tenants → lista tenant + POST /api/v1/tenants → crea tenant + admin + GET /api/v1/tenants/{id} → dettaglio tenant + PATCH /api/v1/tenants/{id} → modifica tenant +""" + +import uuid + +from fastapi import APIRouter + +from app.dependencies import SuperAdminUser, DB +from app.schemas.tenant import TenantCreateRequest, TenantResponse, TenantUpdateRequest +from app.services.tenant_service import TenantService + +router = APIRouter(prefix="/tenants", tags=["Tenant (super-admin)"]) + + +@router.get( + "", + response_model=list[TenantResponse], + summary="Lista tutti i tenant", +) +async def list_tenants( + _: SuperAdminUser, + db: DB, +) -> list[TenantResponse]: + service = TenantService(db) + tenants = await service.list_tenants() + return [TenantResponse.model_validate(t) for t in tenants] + + +@router.post( + "", + response_model=TenantResponse, + status_code=201, + summary="Crea nuovo tenant con admin iniziale", +) +async def create_tenant( + body: TenantCreateRequest, + _: SuperAdminUser, + db: DB, +) -> TenantResponse: + service = TenantService(db) + tenant, _ = await service.create_tenant(body) + return TenantResponse.model_validate(tenant) + + +@router.get( + "/{tenant_id}", + response_model=TenantResponse, + summary="Dettaglio tenant", +) +async def get_tenant( + tenant_id: uuid.UUID, + _: SuperAdminUser, + db: DB, +) -> TenantResponse: + service = TenantService(db) + tenant = await service.get_tenant(tenant_id) + return TenantResponse.model_validate(tenant) + + +@router.patch( + "/{tenant_id}", + response_model=TenantResponse, + summary="Modifica tenant", +) +async def update_tenant( + tenant_id: uuid.UUID, + body: TenantUpdateRequest, + _: SuperAdminUser, + db: DB, +) -> TenantResponse: + service = TenantService(db) + tenant = await service.update_tenant(tenant_id, body) + return TenantResponse.model_validate(tenant) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..36f256a --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,147 @@ +""" +Router utenti – CRUD per admin del tenant. + +Endpoint: + GET /api/v1/users → lista utenti (admin) + POST /api/v1/users → crea utente (admin) + GET /api/v1/users/{id} → dettaglio utente (admin) + PATCH /api/v1/users/{id} → modifica utente (admin) + DELETE /api/v1/users/{id} → disabilita utente (admin) + POST /api/v1/users/{id}/reset-password → reset password (admin) +""" + +import uuid + +from fastapi import APIRouter, Query + +from app.dependencies import AdminUser, DB +from app.schemas.user import ( + UserCreateRequest, + UserListResponse, + UserPasswordResetRequest, + UserResponse, + UserUpdateRequest, +) +from app.services.user_service import UserService + +router = APIRouter(prefix="/users", tags=["Utenti"]) + + +@router.get( + "", + response_model=UserListResponse, + summary="Lista utenti del tenant", +) +async def list_users( + current_user: AdminUser, + db: DB, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=100), +) -> UserListResponse: + service = UserService(db) + users, total = await service.list_users( + tenant_id=current_user.tenant_id, + page=page, + page_size=page_size, + ) + import math + return UserListResponse( + items=[UserResponse.model_validate(u) for u in users], + total=total, + page=page, + page_size=page_size, + pages=math.ceil(total / page_size) if page_size else 0, + ) + + +@router.post( + "", + response_model=UserResponse, + status_code=201, + summary="Crea nuovo utente nel tenant", +) +async def create_user( + body: UserCreateRequest, + current_user: AdminUser, + db: DB, +) -> UserResponse: + service = UserService(db) + user = await service.create_user( + tenant_id=current_user.tenant_id, + data=body, + created_by=current_user, + ) + return UserResponse.model_validate(user) + + +@router.get( + "/{user_id}", + response_model=UserResponse, + summary="Dettaglio utente", +) +async def get_user( + user_id: uuid.UUID, + current_user: AdminUser, + db: DB, +) -> UserResponse: + service = UserService(db) + user = await service.get_user(user_id, current_user.tenant_id) + return UserResponse.model_validate(user) + + +@router.patch( + "/{user_id}", + response_model=UserResponse, + summary="Modifica utente", +) +async def update_user( + user_id: uuid.UUID, + body: UserUpdateRequest, + current_user: AdminUser, + db: DB, +) -> UserResponse: + service = UserService(db) + user = await service.update_user( + user_id=user_id, + tenant_id=current_user.tenant_id, + data=body, + updated_by=current_user, + ) + return UserResponse.model_validate(user) + + +@router.delete( + "/{user_id}", + status_code=204, + summary="Disabilita utente (soft delete)", +) +async def delete_user( + user_id: uuid.UUID, + current_user: AdminUser, + db: DB, +) -> None: + service = UserService(db) + await service.delete_user( + user_id=user_id, + tenant_id=current_user.tenant_id, + deleted_by=current_user, + ) + + +@router.post( + "/{user_id}/reset-password", + status_code=204, + summary="Reset password utente (admin)", +) +async def reset_password( + user_id: uuid.UUID, + body: UserPasswordResetRequest, + current_user: AdminUser, + db: DB, +) -> None: + service = UserService(db) + await service.reset_password( + user_id=user_id, + tenant_id=current_user.tenant_id, + new_password=body.new_password, + ) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..3df24ae --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,90 @@ +""" +Configurazione applicazione – legge variabili d'ambiente tramite pydantic-settings. +""" + +from functools import lru_cache +from typing import Literal + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # ── Applicazione ────────────────────────────────────────────────────────── + app_env: Literal["development", "staging", "production"] = "development" + app_debug: bool = True + app_host: str = "0.0.0.0" + app_port: int = 8000 + app_base_url: str = "http://localhost:8000" + + # ── Sicurezza / JWT ─────────────────────────────────────────────────────── + secret_key: str = "change-me-in-production" + algorithm: str = "HS256" + access_token_expire_minutes: int = 15 + refresh_token_expire_days: int = 30 + + # Chiave AES-256-GCM per cifratura credenziali IMAP/SMTP (hex 64 chars = 32 bytes) + encryption_key: str = "0" * 64 + + # ── Database ────────────────────────────────────────────────────────────── + database_url: str = "postgresql+asyncpg://pecflow:pecflow_dev_password@db:5432/pecflow" + database_url_sync: str = "postgresql://pecflow:pecflow_dev_password@db:5432/pecflow" + + # ── Redis ───────────────────────────────────────────────────────────────── + redis_url: str = "redis://redis:6379/0" + + # ── MinIO ───────────────────────────────────────────────────────────────── + minio_endpoint: str = "minio:9000" + minio_access_key: str = "minioadmin" + minio_secret_key: str = "minioadmin" + minio_bucket: str = "pecflow" + minio_use_ssl: bool = False + + # ── CORS ────────────────────────────────────────────────────────────────── + cors_origins: str = "http://localhost:3000,http://localhost:5173" + + # ── Rate Limiting ───────────────────────────────────────────────────────── + rate_limit_auth: str = "10/minute" + rate_limit_default: str = "100/minute" + + # ── Logging ─────────────────────────────────────────────────────────────── + log_level: str = "INFO" + log_json: bool = False + + @field_validator("encryption_key") + @classmethod + def validate_encryption_key(cls, v: str) -> str: + if len(v) != 64: + raise ValueError( + "ENCRYPTION_KEY deve essere una stringa hex di 64 caratteri (32 bytes)" + ) + try: + bytes.fromhex(v) + except ValueError: + raise ValueError("ENCRYPTION_KEY deve essere una stringa esadecimale valida") + return v + + @property + def cors_origins_list(self) -> list[str]: + return [origin.strip() for origin in self.cors_origins.split(",")] + + @property + def is_production(self) -> bool: + return self.app_env == "production" + + @property + def encryption_key_bytes(self) -> bytes: + return bytes.fromhex(self.encryption_key) + + +@lru_cache +def get_settings() -> Settings: + """Restituisce istanza singleton delle impostazioni (cachata).""" + return Settings() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..8729095 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +# Core utilities diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..4a28ea9 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,123 @@ +""" +Eccezioni applicative custom per PecFlow. +""" + +from fastapi import HTTPException, status + + +# ─── Autenticazione ─────────────────────────────────────────────────────────── + +class InvalidCredentialsError(HTTPException): + def __init__(self) -> None: + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Credenziali non valide", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +class TokenExpiredError(HTTPException): + def __init__(self) -> None: + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token scaduto", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +class TokenInvalidError(HTTPException): + def __init__(self) -> None: + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token non valido", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +class AccountLockedError(HTTPException): + def __init__(self, locked_until: str = "") -> None: + detail = "Account temporaneamente bloccato per troppi tentativi falliti" + if locked_until: + detail += f" fino a {locked_until}" + super().__init__( + status_code=status.HTTP_423_LOCKED, + detail=detail, + ) + + +class AccountDisabledError(HTTPException): + def __init__(self) -> None: + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account disabilitato", + ) + + +class TOTPRequiredError(HTTPException): + def __init__(self) -> None: + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + detail="Autenticazione a due fattori richiesta", + ) + + +class TOTPInvalidError(HTTPException): + def __init__(self) -> None: + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Codice TOTP non valido o scaduto", + ) + + +# ─── Autorizzazione ─────────────────────────────────────────────────────────── + +class ForbiddenError(HTTPException): + def __init__(self, detail: str = "Accesso non autorizzato") -> None: + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + detail=detail, + ) + + +class PermissionDeniedError(HTTPException): + def __init__(self, resource: str = "risorsa") -> None: + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permessi insufficienti per accedere a questa {resource}", + ) + + +# ─── Risorse ────────────────────────────────────────────────────────────────── + +class NotFoundError(HTTPException): + def __init__(self, resource: str = "risorsa") -> None: + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"{resource.capitalize()} non trovata", + ) + + +class ConflictError(HTTPException): + def __init__(self, detail: str = "Conflitto: risorsa già esistente") -> None: + super().__init__( + status_code=status.HTTP_409_CONFLICT, + detail=detail, + ) + + +class TenantLimitExceededError(HTTPException): + def __init__(self, resource: str, limit: int) -> None: + super().__init__( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=f"Limite del piano raggiunto: massimo {limit} {resource} per questo tenant", + ) + + +# ─── Validazione ────────────────────────────────────────────────────────────── + +class ValidationError(HTTPException): + def __init__(self, detail: str) -> None: + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=detail, + ) diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..2fe9e35 --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,65 @@ +""" +Structured logging per PecFlow. +In produzione (LOG_JSON=true) emette log JSON per aggregatori (Loki, ELK). +In sviluppo emette log leggibili colorati. +""" + +import logging +import sys +from typing import Any + +from app.config import get_settings + +settings = get_settings() + + +def _build_handler() -> logging.Handler: + handler = logging.StreamHandler(sys.stdout) + + if settings.log_json: + try: + import json + + class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + log_entry: dict[str, Any] = { + "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + if record.exc_info: + log_entry["exception"] = self.formatException(record.exc_info) + return json.dumps(log_entry, ensure_ascii=False) + + handler.setFormatter(JsonFormatter()) + except Exception: + pass + else: + fmt = "%(asctime)s %(levelname)-8s %(name)s – %(message)s" + handler.setFormatter(logging.Formatter(fmt, datefmt="%H:%M:%S")) + + return handler + + +def setup_logging() -> None: + """Configura il logging applicativo. Da chiamare all'avvio dell'app.""" + level = getattr(logging, settings.log_level.upper(), logging.INFO) + + root_logger = logging.getLogger() + root_logger.setLevel(level) + + # Rimuovi handler esistenti per evitare duplicati + root_logger.handlers.clear() + root_logger.addHandler(_build_handler()) + + # Riduci verbosità librerie rumorose + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.engine").setLevel( + logging.INFO if settings.app_debug else logging.WARNING + ) + + +def get_logger(name: str) -> logging.Logger: + """Restituisce un logger con il nome specificato.""" + return logging.getLogger(name) diff --git a/backend/app/core/pagination.py b/backend/app/core/pagination.py new file mode 100644 index 0000000..b0794f0 --- /dev/null +++ b/backend/app/core/pagination.py @@ -0,0 +1,56 @@ +""" +Utility per paginazione standardizzata nelle API. +""" + +from typing import Generic, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + +DEFAULT_PAGE_SIZE = 25 +MAX_PAGE_SIZE = 100 + + +class PaginationParams(BaseModel): + page: int = Field(default=1, ge=1, description="Numero di pagina (1-based)") + page_size: int = Field( + default=DEFAULT_PAGE_SIZE, + ge=1, + le=MAX_PAGE_SIZE, + description=f"Elementi per pagina (max {MAX_PAGE_SIZE})", + ) + + @property + def offset(self) -> int: + return (self.page - 1) * self.page_size + + @property + def limit(self) -> int: + return self.page_size + + +class PaginatedResponse(BaseModel, Generic[T]): + """Risposta paginata generica.""" + items: list[T] + total: int + page: int + page_size: int + pages: int + + @classmethod + def create( + cls, + items: list[T], + total: int, + params: PaginationParams, + ) -> "PaginatedResponse[T]": + import math + pages = math.ceil(total / params.page_size) if params.page_size > 0 else 0 + return cls( + items=items, + total=total, + page=params.page, + page_size=params.page_size, + pages=pages, + ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..3d75bed --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,158 @@ +""" +Modulo sicurezza – cifratura AES-256-GCM, hashing password, JWT utilities. + +ADR-002: Le credenziali IMAP/SMTP vengono cifrate con AES-256-GCM prima di +essere scritte in DB. La chiave è in variabile d'ambiente (ENCRYPTION_KEY). +Formato storage: base64(nonce_12byte || ciphertext || tag_16byte) +""" + +import base64 +import os +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID + +import bcrypt as _bcrypt +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from jose import JWTError, jwt + +from app.config import get_settings + +settings = get_settings() + +# ─── Password hashing (bcrypt diretto, compatibile con bcrypt 4.x/5.x) ─────── +_BCRYPT_ROUNDS = 12 + + +def hash_password(password: str) -> str: + """Genera hash bcrypt della password (work factor 12).""" + pwd_bytes = password.encode("utf-8") + salt = _bcrypt.gensalt(rounds=_BCRYPT_ROUNDS) + return _bcrypt.hashpw(pwd_bytes, salt).decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verifica password contro il suo hash.""" + try: + return _bcrypt.checkpw( + plain_password.encode("utf-8"), + hashed_password.encode("utf-8"), + ) + except Exception: + return False + + +# ─── JWT ────────────────────────────────────────────────────────────────────── +def create_access_token( + subject: str | UUID, + tenant_id: str | UUID, + role: str, + extra_claims: dict[str, Any] | None = None, +) -> str: + """ + Crea un JWT access token con scadenza configurabile. + + Claims standard: + - sub: user_id (string) + - tid: tenant_id + - role: ruolo utente + - exp: scadenza + - iat: emesso a + """ + now = datetime.now(UTC) + expire = now + timedelta(minutes=settings.access_token_expire_minutes) + + payload: dict[str, Any] = { + "sub": str(subject), + "tid": str(tenant_id), + "role": role, + "iat": now, + "exp": expire, + "type": "access", + } + if extra_claims: + payload.update(extra_claims) + + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + + +def create_refresh_token(subject: str | UUID, tenant_id: str | UUID) -> str: + """ + Crea un JWT refresh token con scadenza lunga (30 giorni default). + Non contiene il ruolo – viene rivalutato a ogni refresh. + """ + now = datetime.now(UTC) + expire = now + timedelta(days=settings.refresh_token_expire_days) + + payload: dict[str, Any] = { + "sub": str(subject), + "tid": str(tenant_id), + "iat": now, + "exp": expire, + "type": "refresh", + } + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + + +def decode_token(token: str) -> dict[str, Any]: + """ + Decodifica e valida un JWT token. + Solleva JWTError se il token è invalido o scaduto. + """ + return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + + +def is_token_valid(token: str, expected_type: str = "access") -> bool: + """Verifica rapidamente la validità del token senza sollevare eccezioni.""" + try: + payload = decode_token(token) + return payload.get("type") == expected_type + except JWTError: + return False + + +# ─── AES-256-GCM cifratura credenziali (ADR-002) ───────────────────────────── +def encrypt_credential(plaintext: str) -> str: + """ + Cifra una stringa con AES-256-GCM usando la chiave applicativa. + + Formato output: base64(nonce_12byte || ciphertext || tag_16byte) + Il tag GCM (16 byte) è automaticamente concatenato al ciphertext da AESGCM. + """ + key = settings.encryption_key_bytes + aesgcm = AESGCM(key) + nonce = os.urandom(12) # 12 byte nonce raccomandato per GCM + + # AESGCM.encrypt() restituisce ciphertext + tag concatenati + ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) + + # Concatena nonce + ciphertext_with_tag e codifica in base64 + raw = nonce + ciphertext_with_tag + return base64.b64encode(raw).decode("ascii") + + +def decrypt_credential(encrypted: str) -> str: + """ + Decifra una stringa cifrata con encrypt_credential(). + Solleva ValueError se la decifratura fallisce (chiave errata o dati corrotti). + """ + key = settings.encryption_key_bytes + aesgcm = AESGCM(key) + + try: + raw = base64.b64decode(encrypted.encode("ascii")) + nonce = raw[:12] + ciphertext_with_tag = raw[12:] + plaintext_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None) + return plaintext_bytes.decode("utf-8") + except Exception as e: + raise ValueError(f"Decifratura fallita: {e}") from e + + +# ─── Hash sicuro per refresh token storage ──────────────────────────────────── +import hashlib + + +def hash_token(token: str) -> str: + """SHA-256 del token raw per storage sicuro in DB.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..4d232ba --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,52 @@ +""" +Configurazione database – engine SQLAlchemy async e session factory. +""" + +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.config import get_settings + +settings = get_settings() + +# Engine async con pool connection +engine = create_async_engine( + settings.database_url, + echo=settings.app_debug, # log SQL in development + pool_size=10, + max_overflow=20, + pool_pre_ping=True, # verifica connessione prima di usarla + pool_recycle=3600, # ricicla connessioni ogni ora +) + +# Session factory +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + + +class Base(DeclarativeBase): + """Base class per tutti i modelli SQLAlchemy.""" + pass + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """ + Dependency FastAPI: restituisce una sessione DB per ogni request. + La sessione viene chiusa automaticamente al termine del request. + """ + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..d808abe --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,117 @@ +""" +Dependency FastAPI – get_db, get_current_user, require_admin, RLS middleware. +""" + +import uuid +from typing import Annotated + +from fastapi import Depends, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ForbiddenError, TokenInvalidError +from app.core.security import decode_token +from app.database import get_db +from app.models.user import User +from sqlalchemy import select + +security = HTTPBearer() + +# ─── Database con RLS ───────────────────────────────────────────────────────── + +async def get_db_with_rls( + tenant_id: uuid.UUID, + db: AsyncSession = Depends(get_db), +) -> AsyncSession: + """ + Imposta la variabile di sessione PostgreSQL per RLS. + Da usare dopo aver estratto il tenant_id dall'utente autenticato. + """ + await db.execute( + text("SET LOCAL app.current_tenant_id = :tenant_id"), + {"tenant_id": str(tenant_id)}, + ) + return db + + +# ─── Utente corrente ────────────────────────────────────────────────────────── + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], + db: AsyncSession = Depends(get_db), +) -> User: + """ + Estrae e valida il JWT dall'header Authorization: Bearer . + Carica l'utente dal DB e imposta RLS. + """ + token = credentials.credentials + + try: + payload = decode_token(token) + except JWTError: + raise TokenInvalidError() + + if payload.get("type") != "access": + raise TokenInvalidError() + + user_id_str = payload.get("sub") + tenant_id_str = payload.get("tid") + + if not user_id_str or not tenant_id_str: + raise TokenInvalidError() + + try: + user_id = uuid.UUID(user_id_str) + tenant_id = uuid.UUID(tenant_id_str) + except ValueError: + raise TokenInvalidError() + + # Imposta RLS per questo tenant + # SET LOCAL non supporta parametri $1, usiamo text() con valore inline + await db.execute( + text(f"SET LOCAL app.current_tenant_id = '{tenant_id!s}'") + ) + + # Carica utente + result = await db.execute( + select(User).where(User.id == user_id, User.tenant_id == tenant_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise TokenInvalidError() + + if not user.is_active: + from app.core.exceptions import AccountDisabledError + raise AccountDisabledError() + + return user + + +# ─── Role guards ────────────────────────────────────────────────────────────── + +async def require_admin( + current_user: Annotated[User, Depends(get_current_user)], +) -> User: + """Richiede ruolo admin o super_admin.""" + if not current_user.is_admin: + raise ForbiddenError("Richiesto ruolo amministratore") + return current_user + + +async def require_super_admin( + current_user: Annotated[User, Depends(get_current_user)], +) -> User: + """Richiede ruolo super_admin.""" + if not current_user.is_super_admin: + raise ForbiddenError("Richiesto ruolo super_admin") + return current_user + + +# ─── Tipo annotato per ridurre boilerplate negli endpoint ───────────────────── +CurrentUser = Annotated[User, Depends(get_current_user)] +AdminUser = Annotated[User, Depends(require_admin)] +SuperAdminUser = Annotated[User, Depends(require_super_admin)] +DB = Annotated[AsyncSession, Depends(get_db)] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..62ec2c8 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,108 @@ +""" +Entrypoint FastAPI – registra router, middleware, startup/shutdown. +""" + +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from fastapi import FastAPI, Request, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware +from slowapi.util import get_remote_address + +from app.api.v1 import auth, permissions, tenants, users +from app.config import get_settings +from app.core.logging import get_logger, setup_logging +from app.database import engine + +settings = get_settings() +logger = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Gestione ciclo di vita dell'applicazione.""" + setup_logging() + logger.info( + "🚀 PecFlow Backend avviato", + extra={"env": settings.app_env, "debug": settings.app_debug}, + ) + yield + # Cleanup: chiudi connessioni DB + await engine.dispose() + logger.info("🛑 PecFlow Backend fermato") + + +# ─── Applicazione FastAPI ───────────────────────────────────────────────────── +limiter = Limiter(key_func=get_remote_address, default_limits=["200/minute"]) + +app = FastAPI( + title="PecFlow API", + description="API per la gestione PEC SaaS multi-tenant", + version="1.0.0", + docs_url="/docs" if not settings.is_production else None, + redoc_url="/redoc" if not settings.is_production else None, + lifespan=lifespan, +) + +# ─── Middleware ─────────────────────────────────────────────────────────────── +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +app.add_middleware(SlowAPIMiddleware) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ─── Router ─────────────────────────────────────────────────────────────────── +API_PREFIX = "/api/v1" + +app.include_router(auth.router, prefix=API_PREFIX) +app.include_router(users.router, prefix=API_PREFIX) +app.include_router(tenants.router, prefix=API_PREFIX) +app.include_router(permissions.router, prefix=API_PREFIX) + + +# ─── Health check ───────────────────────────────────────────────────────────── +@app.get("/health", tags=["Health"], include_in_schema=False) +async def health_check() -> dict: + """Endpoint di health check per Docker/Kubernetes.""" + return { + "status": "ok", + "version": "1.0.0", + "env": settings.app_env, + } + + +@app.get("/health/db", tags=["Health"], include_in_schema=False) +async def health_db() -> dict: + """Verifica connessione al database.""" + from sqlalchemy import text + from app.database import AsyncSessionLocal + + try: + async with AsyncSessionLocal() as session: + await session.execute(text("SELECT 1")) + return {"status": "ok", "database": "connected"} + except Exception as e: + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content={"status": "error", "database": str(e)}, + ) + + +# ─── Error handler globale ──────────────────────────────────────────────────── +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: + logger.exception(f"Errore non gestito: {exc}") + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Errore interno del server"}, + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..2918c02 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,9 @@ +# Importa tutti i modelli per permettere ad Alembic di rilevarli +from app.models.tenant import Tenant # noqa: F401 +from app.models.user import User, RefreshToken # noqa: F401 +from app.models.mailbox import Mailbox # noqa: F401 +from app.models.message import Message, Attachment, SendJob # noqa: F401 +from app.models.archival import ArchivalBatch, ArchivalBatchMessage, ArchivalDip # noqa: F401 +from app.models.audit_log import AuditLog # noqa: F401 +from app.models.label import Label, MessageLabel # noqa: F401 +from app.models.permission import MailboxPermission # noqa: F401 diff --git a/backend/app/models/archival.py b/backend/app/models/archival.py new file mode 100644 index 0000000..b994834 --- /dev/null +++ b/backend/app/models/archival.py @@ -0,0 +1,108 @@ +""" +Modelli Archival – versamenti verso conservatore AgID. +""" + +import uuid +from datetime import date, datetime + +from sqlalchemy import ( + CHAR, + DateTime, + Enum, + ForeignKey, + Index, + Integer, + String, + Text, + func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + +ArchivalStatus = Enum( + "pending", "building_sip", "uploading", "uploaded", + "confirmed", "rejected", "failed", + name="archival_status", + create_type=False, +) + + +class ArchivalBatch(Base): + __tablename__ = "archival_batches" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False + ) + conservatore_id: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[str] = mapped_column(ArchivalStatus, nullable=False, default="pending") + + sip_path: Mapped[str | None] = mapped_column(Text, nullable=True) + sip_checksum: Mapped[str | None] = mapped_column(CHAR(64), nullable=True) + + versamento_id: Mapped[str | None] = mapped_column(Text, nullable=True) + rdv_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + rdv_path: Mapped[str | None] = mapped_column(Text, nullable=True) + rdv_checksum: Mapped[str | None] = mapped_column(CHAR(64), nullable=True) + + attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3) + next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + + period_from: Mapped[date] = mapped_column(nullable=False) + period_to: Mapped[date] = mapped_column(nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + __table_args__ = ( + Index("idx_archival_tenant", "tenant_id"), + Index("idx_archival_status", "status", "next_retry_at"), + ) + + +class ArchivalBatchMessage(Base): + __tablename__ = "archival_batch_messages" + + batch_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("archival_batches.id", ondelete="CASCADE"), + primary_key=True, + ) + message_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("messages.id", ondelete="CASCADE"), + primary_key=True, + ) + + +class ArchivalDip(Base): + __tablename__ = "archival_dips" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False + ) + batch_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("archival_batches.id"), nullable=True + ) + requested_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id"), nullable=True + ) + dip_path: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(50), nullable=False, default="requested") + requested_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..620b32a --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,51 @@ +""" +Modello AuditLog – immutabile per compliance e tracciabilità. +""" + +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + DateTime, + ForeignKey, + Index, + String, + Text, + func, +) +from sqlalchemy.dialects.postgresql import INET, JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class AuditLog(Base): + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + tenant_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True + ) + user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id"), nullable=True + ) + action: Mapped[str] = mapped_column(String(100), nullable=False) + resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True) + resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + ip_address: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) + payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + outcome: Mapped[str] = mapped_column(String(20), nullable=False, default="success") + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + Index("idx_audit_tenant_date", "tenant_id", "occurred_at"), + Index("idx_audit_user", "user_id", "occurred_at"), + Index("idx_audit_action", "action"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/label.py b/backend/app/models/label.py new file mode 100644 index 0000000..4900245 --- /dev/null +++ b/backend/app/models/label.py @@ -0,0 +1,46 @@ +""" +Modelli Label e MessageLabel – tagging messaggi. +""" + +import uuid + +from sqlalchemy import CHAR, ForeignKey, Index, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class Label(Base): + __tablename__ = "labels" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + color: Mapped[str | None] = mapped_column(CHAR(7), nullable=True) # hex #RRGGBB + + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_label_name_tenant"), + ) + + def __repr__(self) -> str: + return f"