GapFill Flowee

This commit is contained in:
2026-06-18 11:24:05 +02:00
parent 64442af182
commit c68daf4313
25 changed files with 2965 additions and 48 deletions
@@ -0,0 +1,36 @@
"""add conservatore_tenant_slug to tenant_settings
Revision ID: 0020
Revises: 0019
Create Date: 2026-06-18
Aggiunge la colonna conservatore_tenant_slug a tenant_settings,
necessaria per i provider che usano autenticazione multi-tenant JWT
(es. Aeterna di Idra Informatica) dove il login richiede anche
un tenant_slug oltre a email e password.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "0020"
down_revision = "0019"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"tenant_settings",
sa.Column(
"conservatore_tenant_slug",
sa.Text(),
nullable=True,
comment="Slug del tenant sul sistema del conservatore (es. 'pechub' per Aeterna)",
),
)
def downgrade() -> None:
op.drop_column("tenant_settings", "conservatore_tenant_slug")
@@ -0,0 +1,80 @@
"""add rem support to mailboxes and messages
Revision ID: 0021
Revises: 0020
Create Date: 2026-06-18
Aggiunge campi per supporto REM (Registered Electronic Mail europea, ETSI EN 319 532-4):
- mailboxes.protocol_type VARCHAR(10) DEFAULT 'pec_it'
Indica il protocollo della casella: 'pec_it' (PEC italiana) o 'rem_eu' (REM europea)
- mailboxes.rem_provider VARCHAR(100) nullable
Nome del provider REM europeo (es. 'docutel', 'anodet', 'de-mail')
- messages.protocol_type VARCHAR(10) DEFAULT 'pec_it'
Protocollo del messaggio, copiato dalla casella al momento della ricezione
- messages.rem_evidence_type VARCHAR(100) nullable
Tipo evidenza REM grezzo (es. 'SubmissionAcceptance', 'DeliveryInformation')
Valorizzato solo per messaggi REM; None per PEC italiane.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "0021"
down_revision = "0020"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── Tabella mailboxes ─────────────────────────────────────────────────────
op.add_column(
"mailboxes",
sa.Column(
"protocol_type",
sa.String(10),
nullable=False,
server_default="pec_it",
comment="Tipo protocollo: pec_it (PEC italiana) | rem_eu (REM europea ETSI EN 319 532-4)",
),
)
op.add_column(
"mailboxes",
sa.Column(
"rem_provider",
sa.String(100),
nullable=True,
comment="Nome provider REM europeo (es. docutel, anodet, de-mail)",
),
)
# ── Tabella messages ──────────────────────────────────────────────────────
op.add_column(
"messages",
sa.Column(
"protocol_type",
sa.String(10),
nullable=False,
server_default="pec_it",
comment="Tipo protocollo del messaggio: pec_it | rem_eu",
),
)
op.add_column(
"messages",
sa.Column(
"rem_evidence_type",
sa.String(100),
nullable=True,
comment=(
"Tipo evidenza REM grezzo (es. SubmissionAcceptance, DeliveryInformation). "
"Valorizzato solo per messaggi REM (protocol_type = 'rem_eu')."
),
),
)
def downgrade() -> None:
op.drop_column("mailboxes", "rem_provider")
op.drop_column("mailboxes", "protocol_type")
op.drop_column("messages", "rem_evidence_type")
op.drop_column("messages", "protocol_type")
+167 -4
View File
@@ -15,6 +15,7 @@ Solo admin e super_admin possono accedere.
Il super_admin puo' operare su qualsiasi tenant tramite query param ?tenant_id=<uuid>.
"""
import time
import uuid
from typing import Annotated, Optional
@@ -23,6 +24,7 @@ from fastapi import APIRouter, HTTPException, Query, status
from app.config import get_settings as get_app_settings
from app.dependencies import AdminUser, DB
from app.schemas.tenant_settings import (
ConservatoreTestResult,
IndexingJobStatus,
IndexingStats,
StartReindexRequest,
@@ -59,15 +61,21 @@ def _resolve_tenant_id(
summary="Legge le impostazioni del tenant",
description=(
"Restituisce la configurazione operativa del tenant: "
"modalita' archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
"modalita' archiviazione (mock/produzione), endpoint e stato credenziali conservatore. "
"Il super_admin puo' specificare ?tenant_id=<uuid> per leggere le impostazioni di un tenant arbitrario."
),
)
async def get_settings(
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare",
),
) -> TenantSettingsResponse:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
service = TenantSettingsService(db)
settings = await service.get_or_create(current_user.tenant_id)
settings = await service.get_or_create(target_tenant_id)
return TenantSettingsService.to_response(settings)
@@ -78,21 +86,176 @@ async def get_settings(
description=(
"Aggiorna la configurazione operativa del tenant. "
"Tutti i campi sono opzionali (semantica PATCH). "
"Il passaggio a modalita' 'production' richiede un endpoint conservatore configurato."
"Il passaggio a modalita' 'production' richiede un endpoint conservatore configurato. "
"Il super_admin puo' specificare ?tenant_id=<uuid> per aggiornare le impostazioni di un tenant arbitrario."
),
)
async def update_settings(
body: TenantSettingsUpdate,
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare",
),
) -> TenantSettingsResponse:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
service = TenantSettingsService(db)
settings = await service.update(current_user.tenant_id, body)
settings = await service.update(target_tenant_id, body)
await db.commit()
await db.refresh(settings)
return TenantSettingsService.to_response(settings)
# ─── Test connessione conservatore ───────────────────────────────────────────
@router.post(
"/test-conservatore",
response_model=ConservatoreTestResult,
summary="Testa la connessione al conservatore configurato",
description=(
"Verifica che le credenziali salvate per il conservatore siano valide "
"effettuando una chiamata di autenticazione reale. "
"Supporta Aeterna (JWT) e conservatori generici (HTTP Basic). "
"Non modifica alcun dato, non invia pacchetti."
),
)
async def test_conservatore_connection(
current_user: AdminUser,
db: DB,
tenant_id: Optional[uuid.UUID] = Query(
default=None,
description="(solo super_admin) UUID del tenant su cui operare",
),
) -> ConservatoreTestResult:
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
service = TenantSettingsService(db)
creds = await service.get_conservatore_credentials(target_tenant_id)
if creds.get("mode") != "production":
return ConservatoreTestResult(
success=False,
message="La modalita' di archiviazione e' impostata su 'mock'. "
"Configura l'endpoint e le credenziali, poi imposta la modalita' su 'produzione'.",
)
endpoint = creds.get("endpoint")
username = creds.get("username")
password = creds.get("password")
tenant_slug = creds.get("tenant_slug")
conservatore_id = creds.get("conservatore_id", "")
if not endpoint or not username or not password:
return ConservatoreTestResult(
success=False,
message="Credenziali o endpoint non configurati. "
"Compila tutti i campi obbligatori prima di testare la connessione.",
)
# Rileva Aeterna da conservatore_id o URL
is_aeterna = (
(conservatore_id or "").lower() == "aeterna"
or "aeterna" in (endpoint or "").lower()
or "idrainformatica" in (endpoint or "").lower()
)
t_start = time.monotonic()
if is_aeterna:
# Test Aeterna: POST /api/v1/auth/login + GET /api/v1/auth/me
if not tenant_slug:
return ConservatoreTestResult(
success=False,
message="Provider Aeterna richiede il campo 'Tenant Slug'. Configuralo nelle impostazioni.",
)
try:
import httpx
async with httpx.AsyncClient(timeout=15) as client:
resp_login = await client.post(
f"{endpoint.rstrip('/')}/api/v1/auth/login",
json={
"email": username,
"password": password,
"tenant_slug": tenant_slug,
},
)
latency_ms = int((time.monotonic() - t_start) * 1000)
if resp_login.status_code != 200:
return ConservatoreTestResult(
success=False,
message=f"Login Aeterna fallito (HTTP {resp_login.status_code}): {resp_login.text[:200]}",
latency_ms=latency_ms,
)
login_data = resp_login.json()
token = login_data.get("access_token")
async with httpx.AsyncClient(timeout=10) as client:
resp_me = await client.get(
f"{endpoint.rstrip('/')}/api/v1/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
latency_ms = int((time.monotonic() - t_start) * 1000)
me = resp_me.json() if resp_me.status_code == 200 else {}
return ConservatoreTestResult(
success=resp_me.status_code == 200,
message=(
f"Connessione ad Aeterna riuscita (utente: {me.get('email', '?')})"
if resp_me.status_code == 200
else f"Login riuscito ma /me ha risposto HTTP {resp_me.status_code}"
),
latency_ms=latency_ms,
provider_info={
"platform": "Aeterna",
"tenant_slug": tenant_slug,
"user_email": me.get("email"),
"permissions_count": len(me.get("permissions", [])),
} if me else None,
)
except Exception as e:
return ConservatoreTestResult(
success=False,
message=f"Errore connessione ad Aeterna: {e}",
latency_ms=int((time.monotonic() - t_start) * 1000),
)
else:
# Test generico: HTTP Basic HEAD o GET sull'endpoint
import base64
import httpx
auth_str = base64.b64encode(f"{username}:{password}".encode()).decode()
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(
endpoint,
headers={"Authorization": f"Basic {auth_str}"},
)
latency_ms = int((time.monotonic() - t_start) * 1000)
if response.status_code < 500:
return ConservatoreTestResult(
success=True,
message=f"Endpoint raggiungibile (HTTP {response.status_code})",
latency_ms=latency_ms,
)
else:
return ConservatoreTestResult(
success=False,
message=f"Endpoint ha risposto con errore HTTP {response.status_code}",
latency_ms=latency_ms,
)
except Exception as e:
return ConservatoreTestResult(
success=False,
message=f"Errore connessione: {e}",
latency_ms=int((time.monotonic() - t_start) * 1000),
)
# ─── Indicizzazione full-text ─────────────────────────────────────────────────
@router.get(
+6
View File
@@ -65,6 +65,12 @@ class Mailbox(Base):
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Protocollo (Feature N8 REM europea)
# 'pec_it' → PEC italiana (default)
# 'rem_eu' → REM europea (ETSI EN 319 532-4)
protocol_type: Mapped[str] = mapped_column(String(10), nullable=False, default="pec_it")
rem_provider: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
+6
View File
@@ -115,6 +115,12 @@ class Message(Base):
risk_level: Mapped[str | None] = mapped_column(RiskLevel, nullable=True)
confidentiality: Mapped[str | None] = mapped_column(ConfidentialityLevel, nullable=True)
# Protocollo e REM europea (Feature N8)
# protocol_type: 'pec_it' (default) o 'rem_eu'
# rem_evidence_type: valore grezzo dell'header X-REM-Evidence-Type
protocol_type: Mapped[str] = mapped_column(String(10), nullable=False, default="pec_it")
rem_evidence_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
# Full-text search vector (aggiornato da trigger DB + worker per allegati)
+3
View File
@@ -51,6 +51,9 @@ class TenantSettings(Base):
conservatore_username_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
conservatore_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
# Slug del tenant sul sistema del conservatore (es. 'pechub' per Aeterna)
conservatore_tenant_slug: Mapped[str | None] = mapped_column(Text, nullable=True)
# Note operative opzionali
archival_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+19
View File
@@ -19,6 +19,17 @@ class MailboxCreateRequest(BaseModel):
display_name: str | None = Field(None, max_length=255, description="Nome visualizzato")
provider: str | None = Field(None, max_length=100, description="Provider PEC (aruba, namirial...)")
# Protocollo (Feature N8 REM europea)
protocol_type: Literal["pec_it", "rem_eu"] = Field(
"pec_it",
description="Tipo protocollo: pec_it (PEC italiana, default) | rem_eu (REM europea ETSI EN 319 532-4)",
)
rem_provider: str | None = Field(
None,
max_length=100,
description="Nome provider REM europeo (es. docutel, anodet, de-mail). Solo per rem_eu.",
)
# Credenziali IMAP (in chiaro, cifrate prima della persistenza)
imap_host: str = Field(..., min_length=1, max_length=255, description="Host IMAP")
imap_port: int = Field(993, ge=1, le=65535, description="Porta IMAP")
@@ -48,6 +59,10 @@ class MailboxUpdateRequest(BaseModel):
provider: str | None = Field(None, max_length=100)
status: Literal["active", "paused"] | None = None
# Aggiornamento protocollo (Feature N8)
protocol_type: Literal["pec_it", "rem_eu"] | None = None
rem_provider: str | None = Field(None, max_length=100)
# Aggiornamento credenziali IMAP (opzionale)
imap_host: str | None = Field(None, min_length=1, max_length=255)
imap_port: int | None = Field(None, ge=1, le=65535)
@@ -84,6 +99,10 @@ class MailboxResponse(BaseModel):
smtp_port: int
smtp_use_tls: bool
# Protocollo (Feature N8)
protocol_type: str
rem_provider: str | None
# Stato sync
status: str
last_sync_at: datetime | None
+12
View File
@@ -28,6 +28,7 @@ class TenantSettingsResponse(BaseModel):
archival_mode: ArchivalMode
conservatore_id: str
conservatore_endpoint: str | None
conservatore_tenant_slug: str | None
conservatore_username_configured: bool # TRUE se la username è già salvata
conservatore_password_configured: bool # TRUE se la password è già salvata
archival_notes: str | None
@@ -56,6 +57,9 @@ class TenantSettingsUpdate(BaseModel):
# URL endpoint del conservatore (obbligatorio in produzione, ignorato in mock)
conservatore_endpoint: str | None = None
# Slug tenant sul sistema del conservatore (es. 'pechub' per Aeterna)
conservatore_tenant_slug: str | None = None
# Credenziali in chiaro: vengono cifrate prima del salvataggio.
# Valore stringa vuota ("") = cancella la credenziale.
conservatore_username: str | None = None
@@ -103,6 +107,14 @@ class IndexingJobStatus(BaseModel):
error: Optional[str] = None
class ConservatoreTestResult(BaseModel):
"""Risposta POST /settings/test-conservatore."""
success: bool
message: str
latency_ms: int | None = None
provider_info: dict | None = None
class StartReindexRequest(BaseModel):
"""Body per POST /settings/indexing/reindex."""
@@ -73,6 +73,9 @@ class TenantSettingsService:
if data.conservatore_endpoint is not None:
settings.conservatore_endpoint = data.conservatore_endpoint or None
if data.conservatore_tenant_slug is not None:
settings.conservatore_tenant_slug = data.conservatore_tenant_slug or None
if data.archival_notes is not None:
settings.archival_notes = data.archival_notes or None
@@ -118,6 +121,7 @@ class TenantSettingsService:
archival_mode=settings.archival_mode, # type: ignore[arg-type]
conservatore_id=settings.conservatore_id,
conservatore_endpoint=settings.conservatore_endpoint,
conservatore_tenant_slug=settings.conservatore_tenant_slug,
conservatore_username_configured=settings.conservatore_username_enc is not None,
conservatore_password_configured=settings.conservatore_password_enc is not None,
archival_notes=settings.archival_notes,
@@ -139,6 +143,7 @@ class TenantSettingsService:
"mode": settings.archival_mode,
"conservatore_id": settings.conservatore_id,
"endpoint": settings.conservatore_endpoint,
"tenant_slug": settings.conservatore_tenant_slug,
"username": (
decrypt_credential(settings.conservatore_username_enc)
if settings.conservatore_username_enc
+538
View File
@@ -0,0 +1,538 @@
# Integrazione Aeterna Archiviazione Sostitutiva PecHub
Documento tecnico sull'integrazione di PecHub con Aeterna,
piattaforma di conservazione digitale conforme E-ARK gestita da Idra Informatica.
---
## Indice
1. [Panoramica del provider](#1-panoramica-del-provider)
2. [Architettura Aeterna](#2-architettura-aeterna)
3. [Autenticazione JWT](#3-autenticazione-jwt)
4. [Ingest Upload SIP](#4-ingest--upload-sip)
5. [Formato SIP BagIt (RFC 8493)](#5-formato-sip-bagit-rfc-8493)
6. [Polling stato ingest](#6-polling-stato-ingest)
7. [Disseminazione (DIP)](#7-disseminazione-dip)
8. [Mapping stati Aeterna → PecHub](#8-mapping-stati-aeterna--pechub)
9. [Configurazione in PecHub](#9-configurazione-in-pechub)
10. [Esempi curl completi](#10-esempi-curl-completi)
11. [Note operative](#11-note-operative)
---
## 1. Panoramica del provider
| Campo | Valore |
|-------------------|-----------------------------------------------|
| Provider | Idra Informatica srl |
| Piattaforma | Aeterna v0.1.0 |
| URL applicazione | https://aeterna.idrainformatica.it |
| Endpoint API | https://api.aeterna.idrainformatica.it |
| Documentazione | https://api.aeterna.idrainformatica.it/docs |
| OpenAPI JSON | https://api.aeterna.idrainformatica.it/openapi.json |
| Standard | E-ARK CSIP 2.1.0, BagIt RFC 8493, PREMIS 3.0, METS 1.12 |
### Credenziali tenant PecHub
| Campo | Valore |
|-------------|---------------------------------|
| Org. name | pechub |
| Tenant slug | pechub |
| Username | matteo@idrainformatica.it |
| Password | (cifrata nel DB PecHub) |
| Tenant ID | 366d3d51-b25d-46fc-8f9c-9bc28d902620 |
---
## 2. Architettura Aeterna
Aeterna e' una piattaforma multi-tenant FastAPI (Python).
Ogni organizzazione (tenant) ha:
- Container MinIO dedicato per lo storage isolato
- Collection Apache Solr dedicata per la ricerca full-text
- RBAC (Role-Based Access Control) per-tenant
Il ciclo di vita di un documento archiviale in Aeterna:
```
SIP (upload) → AIP (ingest pipeline) → DIP (disseminazione)
| | |
BagIt ZIP PREMIS events ZIP scaricabile
multipart METS 1.12 generato
form-data validazione E-ARK
```
---
## 3. Autenticazione JWT
Aeterna usa JWT Bearer token, NON HTTP Basic.
Il token ha durata 3600 secondi (1 ora). Esiste un refresh token.
### Login
```
POST /api/v1/auth/login
Content-Type: application/json
```
Body:
```json
{
"email": "matteo@idrainformatica.it",
"password": "...",
"tenant_slug": "pechub"
}
```
Risposta 200:
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 3600,
"user": {
"id": "e3cac60b-d942-4590-94fe-932c0e14e836",
"email": "matteo@idrainformatica.it",
"full_name": "Matteo Giustini",
"is_platform_admin": false,
"tenant_id": "366d3d51-b25d-46fc-8f9c-9bc28d902620",
"permissions": [
"ingest:submit", "ingest:manage", "packages:read",
"packages:create", "dissemination:read", "dissemination:download",
"preservation:manage", "audit:read", "settings:manage", ...
]
}
}
```
Utilizzo del token nelle richieste successive:
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```
### Refresh token
```
POST /api/v1/auth/refresh
Content-Type: application/json
{"refresh_token": "eyJhbGciOiJIUzI1NiIs..."}
```
### Verifica identita'
```
GET /api/v1/auth/me
Authorization: Bearer <token>
```
---
## 4. Ingest Upload SIP
L'endpoint di ingest accetta pacchetti SIP in due formati:
- **E-ARK CSIP v2.2.0** (ZIP con METS.xml in root)
- **BagIt RFC 8493** (ZIP con bagit.txt + data/) — **formato scelto per PecHub**
### Endpoint
```
POST /api/v1/ingest/upload
Authorization: Bearer <token>
Content-Type: multipart/form-data
```
### Campi form-data
| Campo | Tipo | Obbligatorio | Descrizione |
|---------------------|--------|:------------:|--------------------------------------|
| `file` | file | si | ZIP SIP (E-ARK CSIP o BagIt) |
| `title` | string | si | Titolo del pacchetto |
| `description` | string | no | Descrizione |
| `creator` | string | no | Nome del produttore |
| `submission_agreement` | string | no | ID o URL dell'accordo di versamento |
| `ead3_file` | file | no | EAD3 finding aid XML (GAP-09) |
| `eac_cpf_file` | file | no | EAC-CPF authority record XML |
### Risposta 202 Accepted
```json
{
"package_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"pid": "urn:pechub:aip:a1b2c3d4",
"status": "UPLOADED",
"task_id": "celery-task-uuid",
"message": "SIP uploaded successfully. Processing started.",
"submitted_at": "2026-06-18T09:00:00Z"
}
```
Il `package_id` e' l'identificatore da usare per polling e DIP.
---
## 5. Formato SIP BagIt (RFC 8493)
PecHub costruisce pacchetti BagIt in memoria (`build_bagit_sip` in `conservatore_client.py`).
### Struttura ZIP generata
```
pechub-pec-{message_id}/
bagit.txt # Dichiarazione BagIt (obbligatorio)
bag-info.txt # Metadati descrittivi (opzionale)
manifest-sha256.txt # Checksum SHA-256 dei file in data/
data/
{message_id}.eml # Messaggio PEC grezzo
```
### Contenuto bagit.txt
```
BagIt-Version: 1.0
Tag-File-Character-Encoding: UTF-8
```
### Contenuto bag-info.txt (esempio)
```
Bag-Software-Agent: PecHub Archival Module
Bagging-Date: 2026-06-18
External-Identifier: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Source-Organization: PecHub
Description: PEC oggetto del messaggio (max 500 char)
Contact-Email: mittente@pec.it
External-Description: PEC a destinatario@pec.it
Bag-Group-Identifier: 2026-06-18
```
### Contenuto manifest-sha256.txt
```
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 data/a1b2c3d4-e5f6.eml
```
### Rilevamento automatico BagIt da Aeterna
Aeterna rileva automaticamente il formato BagIt dalla presenza di `bagit.txt`
nella root del bag all'interno dello ZIP. Non e' necessario specificare il formato.
La pipeline verifica i checksum del manifest prima dell'ingest.
---
## 6. Polling stato ingest
Dopo l'upload, la pipeline di Aeterna processa il pacchetto in modo asincrono.
### Endpoint status
```
GET /api/v1/ingest/{package_id}/status
Authorization: Bearer <token>
```
### Risposta
```json
{
"package_id": "a1b2c3d4-...",
"pid": "urn:pechub:aip:a1b2c3d4",
"status": "PROCESSING",
"task_id": "celery-task-uuid",
"pipeline_stage": "format_identification",
"progress_pct": 40,
"steps": [
{"name": "validation", "status": "completed", ...},
{"name": "format_identification", "status": "running", ...},
{"name": "virus_scan", "status": "pending", ...}
],
"is_valid": null,
"validation_errors": 0,
"error_message": null,
"submitted_at": "2026-06-18T09:00:00Z",
"completed_at": null
}
```
### Stati possibili
| Status Aeterna | Significato | Stato PecHub |
|----------------|------------------------------------------|--------------|
| `UPLOADED` | SIP ricevuto, elaborazione non iniziata | `uploading` |
| `VALIDATING` | Validazione E-ARK/BagIt in corso | `uploading` |
| `PROCESSING` | Pipeline ingest in corso | `uploading` |
| `INGESTING` | AIP in fase di creazione | `uploading` |
| `ACTIVE` | AIP attivo, conservazione completata | `confirmed` |
| `FAILED` | Pipeline fallita con errori | `failed` |
| `REJECTED` | Pacchetto non conforme agli standard | `rejected` |
| `DELETED` | Soft-delete | `rejected` |
### Report completo
```
GET /api/v1/ingest/{package_id}/report
Authorization: Bearer <token>
```
---
## 7. Disseminazione (DIP)
### Richiesta generazione DIP
```
POST /api/v1/packages/{package_id}/disseminate
Authorization: Bearer <token>
Content-Type: application/json
{"note": "Richiesto da PecHub Archival Module"}
```
Risposta 202:
```json
{
"id": "dip-uuid",
"pid": "urn:pechub:dip:...",
"status": "PROCESSING",
"size_bytes": null,
"is_available": false
}
```
### Verifica stato DIP
```
GET /api/v1/packages/{package_id}/dip
Authorization: Bearer <token>
```
Quando `is_available: true`, il DIP e' scaricabile.
### Download DIP
```
GET /api/v1/packages/{package_id}/dip/download
Authorization: Bearer <token>
```
Risposta: stream ZIP del DIP.
---
## 8. Mapping stati Aeterna → PecHub
```python
_STATUS_MAP = {
"UPLOADED": "uploading",
"VALIDATING": "uploading",
"PROCESSING": "uploading",
"INGESTING": "uploading",
"ACTIVE": "confirmed", # conservazione completata
"FAILED": "failed",
"REJECTED": "rejected",
"DELETED": "rejected",
}
```
Il `versamento_id` in PecHub corrisponde al `package_id` di Aeterna (UUID v4).
---
## 9. Configurazione in PecHub
### Impostazioni tenant
Dalla pagina **Impostazioni → Archiviazione Sostitutiva**:
| Campo | Valore per Aeterna |
|-------------------------|---------------------------------------------|
| Modalita' | Produzione |
| Identificativo conservatore | `aeterna` |
| URL endpoint API | `https://api.aeterna.idrainformatica.it` |
| Tenant Slug | `pechub` |
| Username | `matteo@idrainformatica.it` |
| Password | (da fornire, viene cifrata AES-256-GCM) |
### Riconoscimento automatico del provider
Il factory `ConservatoreClient.from_tenant_credentials()` rileva Aeterna se:
- `conservatore_id == "aeterna"` (case-insensitive), OPPURE
- `"aeterna"` e' presente nell'URL endpoint, OPPURE
- `"idrainformatica"` e' presente nell'URL endpoint
Se rilevato come Aeterna, usa `AeternaConservatoreClient` (JWT + BagIt).
Altrimenti usa `ProductionConservatoreClient` (HTTP Basic, standard AgID legacy).
### Test connessione
Dopo aver configurato e salvato le impostazioni, usare il pulsante
**"Testa connessione"** in Impostazioni → Archiviazione Sostitutiva.
Oppure via API:
```
POST /api/v1/settings/test-conservatore
Authorization: Bearer <pechub-token>
```
Risposta:
```json
{
"success": true,
"message": "Connessione ad Aeterna riuscita (utente: matteo@idrainformatica.it)",
"latency_ms": 342,
"provider_info": {
"platform": "Aeterna",
"tenant_slug": "pechub",
"user_email": "matteo@idrainformatica.it",
"permissions_count": 20
}
}
```
---
## 10. Esempi curl completi
### Login
```bash
TOKEN=$(curl -s -X POST https://api.aeterna.idrainformatica.it/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"matteo@idrainformatica.it","password":"...","tenant_slug":"pechub"}' \
| jq -r .access_token)
echo "Token: $TOKEN"
```
### Upload SIP BagIt
```bash
# Crea un BagIt minimo di test
mkdir -p /tmp/testbag/data
echo "Hello PEC" > /tmp/testbag/data/test.eml
SHA=$(sha256sum /tmp/testbag/data/test.eml | awk '{print $1}')
cat > /tmp/testbag/bagit.txt <<EOF
BagIt-Version: 1.0
Tag-File-Character-Encoding: UTF-8
EOF
cat > /tmp/testbag/manifest-sha256.txt <<EOF
$SHA data/test.eml
EOF
# ZIP del bag
cd /tmp && zip -r testbag.zip testbag/
# Upload
curl -s -X POST https://api.aeterna.idrainformatica.it/api/v1/ingest/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/tmp/testbag.zip;type=application/zip" \
-F "title=Test PEC da PecHub" \
-F "description=Messaggio di test" \
-F "creator=PecHub" \
| jq .
```
### Polling status
```bash
PACKAGE_ID="a1b2c3d4-e5f6-7890-abcd-ef1234567890"
curl -s https://api.aeterna.idrainformatica.it/api/v1/ingest/$PACKAGE_ID/status \
-H "Authorization: Bearer $TOKEN" \
| jq '{status:.status, stage:.pipeline_stage, pct:.progress_pct}'
```
### Lista pacchetti
```bash
curl -s "https://api.aeterna.idrainformatica.it/api/v1/packages?limit=10" \
-H "Authorization: Bearer $TOKEN" \
| jq '.items[] | {id:.id, title:.title, status:.status}'
```
### Report ingest
```bash
curl -s https://api.aeterna.idrainformatica.it/api/v1/ingest/$PACKAGE_ID/report \
-H "Authorization: Bearer $TOKEN" \
| jq .
```
### Richiesta DIP
```bash
curl -s -X POST \
"https://api.aeterna.idrainformatica.it/api/v1/packages/$PACKAGE_ID/disseminate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"note":"Richiesto da PecHub"}' \
| jq .
```
### Download DIP
```bash
curl -L -o /tmp/dip.zip \
"https://api.aeterna.idrainformatica.it/api/v1/packages/$PACKAGE_ID/dip/download" \
-H "Authorization: Bearer $TOKEN"
```
---
## 11. Note operative
### Dimensione pacchetti
Non ci sono limiti documentati. I file EML di PEC sono tipicamente 10-500 KB.
Il timeout HTTP nel client e' impostato a 120 secondi per l'upload.
### Concorrenza
Ogni `AeternaConservatoreClient` gestisce il token JWT in memoria.
Se il worker usa piu' istanze concorrenti, ogni istanza effettua il suo login.
Il token dura 3600s, il rinnovo automatico avviene 60s prima della scadenza.
### Retry policy
In caso di fallimento dell'upload, il batch `ArchivalBatch` in PecHub
ha `max_attempts=3` e un `next_retry_at` con back-off esponenziale.
### Standard di riferimento
- **E-ARK CSIP v2.1.0** — Common Specification for Information Packages
- **E-ARK SIP/AIP/DIP** — Submission/Archival/Dissemination Information Package
- **BagIt RFC 8493** — formato file standard per trasferimento dati
- **PREMIS 3.0** — metadati di preservazione
- **METS 1.12** — Metadata Encoding and Transmission Standard
- **Dublin Core / EAD** — metadati descrittivi
### Codice sorgente rilevante
| File | Descrizione |
|------|-------------|
| `worker/app/archival/conservatore_client.py` | Client completo con AeternaConservatoreClient, BagIt builder, factory |
| `worker/scripts/test_aeterna_transmission.py` | Script di test standalone |
| `backend/app/api/v1/settings.py` | Endpoint test-conservatore |
| `backend/app/models/tenant_settings.py` | Modello DB con conservatore_tenant_slug |
| `backend/app/services/tenant_settings_service.py` | Servizio credenziali |
| `backend/alembic/versions/0020_add_conservatore_tenant_slug.py` | Migrazione DB |
| `frontend/src/pages/Settings/SettingsPage.tsx` | UI configurazione conservatore |
| `frontend/src/api/settings.api.ts` | Client API frontend |
### Esecuzione script di test
```bash
# Dal server, dentro il container worker
docker exec -it pechub-worker-1 \
python /app/scripts/test_aeterna_transmission.py
# I risultati vengono salvati in /tmp/aeterna_test_results.json
```
---
*Documento generato il 2026-06-18. Versione Aeterna: 0.1.0.*
+31 -5
View File
@@ -22,6 +22,8 @@ export interface TenantSettingsResponse {
archival_mode: ArchivalMode
conservatore_id: string
conservatore_endpoint: string | null
/** Slug del tenant sul sistema del conservatore (es. 'pechub' per Aeterna) */
conservatore_tenant_slug: string | null
conservatore_username_configured: boolean
conservatore_password_configured: boolean
archival_notes: string | null
@@ -34,6 +36,8 @@ export interface TenantSettingsUpdate {
conservatore_id?: string
/** URL endpoint API del conservatore (obbligatorio in produzione) */
conservatore_endpoint?: string
/** Slug del tenant sul sistema del conservatore (es. 'pechub' per Aeterna) */
conservatore_tenant_slug?: string
/** Username in chiaro viene cifrata lato server. Stringa vuota = cancella */
conservatore_username?: string
/** Password in chiaro viene cifrata lato server. Stringa vuota = cancella */
@@ -41,6 +45,13 @@ export interface TenantSettingsUpdate {
archival_notes?: string
}
export interface ConservatoreTestResult {
success: boolean
message: string
latency_ms: number | null
provider_info: Record<string, unknown> | null
}
// ─── Tipi indicizzazione full-text ─────────────────────────────────────────
export interface IndexingStats {
@@ -76,20 +87,35 @@ export interface IndexingJobStatus {
export const settingsApi = {
/**
* Recupera le impostazioni del tenant corrente.
* Recupera le impostazioni del tenant.
* Se non esistono, il backend le crea con i valori di default (mock).
* @param tenantId - (solo super_admin) UUID del tenant target
*/
get: async (): Promise<TenantSettingsResponse> => {
const { data } = await apiClient.get<TenantSettingsResponse>('/settings')
get: async (tenantId?: string): Promise<TenantSettingsResponse> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.get<TenantSettingsResponse>('/settings', { params })
return data
},
/**
* Aggiorna le impostazioni del tenant.
* Solo i campi forniti vengono modificati (semantica PATCH).
* @param tenantId - (solo super_admin) UUID del tenant target
*/
update: async (payload: TenantSettingsUpdate): Promise<TenantSettingsResponse> => {
const { data } = await apiClient.put<TenantSettingsResponse>('/settings', payload)
update: async (payload: TenantSettingsUpdate, tenantId?: string): Promise<TenantSettingsResponse> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.put<TenantSettingsResponse>('/settings', payload, { params })
return data
},
/**
* Testa la connessione al conservatore configurato.
* Effettua una chiamata reale (login + verifica identità) senza modificare dati.
* @param tenantId - (solo super_admin) UUID del tenant target
*/
testConservatore: async (tenantId?: string): Promise<ConservatoreTestResult> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.post<ConservatoreTestResult>('/settings/test-conservatore', undefined, { params })
return data
},
+53 -1
View File
@@ -10,9 +10,10 @@ import {
CheckCircle,
Clock,
RefreshCw,
Globe,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { useForm } from 'react-hook-form'
import { useForm, useWatch } from 'react-hook-form'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
@@ -174,6 +175,14 @@ export function MailboxesPage() {
{mailbox.provider}
</span>
)}
{/* Badge REM europea (Feature N8) */}
{mailbox.protocol_type === 'rem_eu' && (
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-100">
<Globe className="h-3 w-3" />
REM EU
{mailbox.rem_provider && ` · ${mailbox.rem_provider}`}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">{mailbox.email_address}</p>
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
@@ -278,6 +287,7 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<MailboxCreateRequest>({
defaultValues: editingMailbox
@@ -285,6 +295,8 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
email_address: editingMailbox.email_address,
display_name: editingMailbox.display_name || '',
provider: editingMailbox.provider || '',
protocol_type: editingMailbox.protocol_type || 'pec_it',
rem_provider: editingMailbox.rem_provider || '',
imap_host: editingMailbox.imap_host,
imap_port: editingMailbox.imap_port,
imap_user: editingMailbox.email_address,
@@ -295,6 +307,7 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
smtp_use_tls: editingMailbox.smtp_use_tls,
}
: {
protocol_type: 'pec_it',
imap_port: 993,
smtp_port: 465,
imap_use_ssl: true,
@@ -302,6 +315,10 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
},
})
// Osserva il campo protocol_type per mostrare/nascondere rem_provider
const selectedProtocol = useWatch({ control, name: 'protocol_type' })
const isRemEu = selectedProtocol === 'rem_eu'
const createMutation = useMutation({
mutationFn: mailboxesApi.create,
onSuccess: () => {
@@ -363,6 +380,41 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
</div>
</div>
{/* Protocollo (Feature N8 REM europea) */}
<div className="space-y-3 rounded-lg border p-4">
<h4 className="text-sm font-semibold flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
Protocollo
</h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Tipo protocollo</Label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
{...register('protocol_type')}
>
<option value="pec_it">PEC italiana (default)</option>
<option value="rem_eu">REM europea (ETSI EN 319 532-4)</option>
</select>
</div>
{isRemEu && (
<div className="space-y-1.5">
<Label className="text-xs">Provider REM europeo</Label>
<Input
placeholder="es. docutel, anodet, de-mail"
{...register('rem_provider')}
/>
</div>
)}
</div>
{isRemEu && (
<p className="text-xs text-blue-700 bg-blue-50 rounded p-2">
Casella configurata per REM europea (ETSI EN 319 532-4). I messaggi in arrivo
con header X-REM-* verranno classificati automaticamente con il parser REM.
</p>
)}
</div>
{/* Separatore IMAP */}
<div className="space-y-3 rounded-lg border p-4">
<h4 className="text-sm font-semibold">Configurazione IMAP (ricezione)</h4>
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
@@ -309,8 +309,22 @@ function TaxonomyWidget({ messageId, messageLabels }: { messageId: string; messa
queryFn: () => labelsApi.list(),
})
// Le label tassonomiche del messaggio sono quelle con parent_id != null
const taxonomyLabels = messageLabels.filter((l) => l.parent_id !== null)
// Le label tassonomiche del messaggio: nodi figli (parent_id != null) PIU' i nodi
// radice che hanno almeno un figlio nell'albero (identificati come parent_id di
// qualche altra label). Questo permette di visualizzare anche gli Ambiti (livello 0)
// quando sono assegnati direttamente a un messaggio.
const taxonomyIdSet = useMemo(() => {
const ids = new Set<string>()
allLabels.forEach((l: LabelResponse) => {
if (l.parent_id !== null) {
ids.add(l.id) // nodo figlio -> tassonomico
ids.add(l.parent_id) // suo genitore radice -> anch'esso tassonomico
}
})
return ids
}, [allLabels])
const taxonomyLabels = messageLabels.filter((l) => taxonomyIdSet.has(l.id))
// Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione"
function buildPath(labelId: string): string {
+152 -7
View File
@@ -37,10 +37,13 @@ import {
import { useAuth } from '@/hooks/useAuth'
import { useAuthStore } from '@/store/auth.store'
import { usersApi } from '@/api/users.api'
import { tenantsApi } from '@/api/tenants.api'
import type { TenantResponse } from '@/types/api.types'
import {
settingsApi,
type TenantSettingsResponse,
type ArchivalMode,
type ConservatoreTestResult,
type IndexingStats,
type IndexingJobStatus,
type ReindexMode,
@@ -973,32 +976,53 @@ export function SettingsPage() {
const [loadingArchival, setLoadingArchival] = useState(false)
const [archivalExpanded, setArchivalExpanded] = useState(false)
// Selector tenant per super_admin
const [tenantList, setTenantList] = useState<TenantResponse[]>([])
const [selectedTenantId, setSelectedTenantId] = useState<string | undefined>(undefined)
// Form archiviazione
const [archivalMode, setArchivalMode] = useState<ArchivalMode>('mock')
const [conservatoreId, setConservatoreId] = useState('')
const [conservatoreEndpoint, setConservatoreEndpoint] = useState('')
const [conservatoreTenantSlug, setConservatoreTenantSlug] = useState('')
const [conservatoreUsername, setConservatoreUsername] = useState('')
const [conservatorePassword, setConservatorePassword] = useState('')
const [archivalNotes, setArchivalNotes] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [savingArchival, setSavingArchival] = useState(false)
const [showProductionConfirm, setShowProductionConfirm] = useState(false)
const [testingConnection, setTestingConnection] = useState(false)
const [testResult, setTestResult] = useState<ConservatoreTestResult | null>(null)
/* ── Carica lista tenant (solo super_admin) ── */
useEffect(() => {
if (isSuperAdmin) {
tenantsApi.list().then(setTenantList).catch(() => {
// Silenzioso: la lista e' un extra di comodita'
})
}
}, [isSuperAdmin])
/* ── Carica impostazioni archiviazione ── */
useEffect(() => {
if (isAdmin) {
loadArchivalSettings()
loadArchivalSettings(selectedTenantId)
}
}, [isAdmin])
}, [isAdmin, selectedTenantId])
const loadArchivalSettings = async () => {
const loadArchivalSettings = async (tenantId?: string) => {
setLoadingArchival(true)
// Resetta credenziali ogni volta che si cambia tenant
setConservatoreUsername('')
setConservatorePassword('')
setTestResult(null)
try {
const data = await settingsApi.get()
const data = await settingsApi.get(tenantId)
setArchivalSettings(data)
setArchivalMode(data.archival_mode)
setConservatoreId(data.conservatore_id)
setConservatoreEndpoint(data.conservatore_endpoint ?? '')
setConservatoreTenantSlug(data.conservatore_tenant_slug ?? '')
setArchivalNotes(data.archival_notes ?? '')
} catch {
toast.error('Errore durante il caricamento delle impostazioni di archiviazione')
@@ -1050,6 +1074,27 @@ export function SettingsPage() {
}
}
/* ── Test connessione conservatore ── */
const handleTestConnection = async () => {
setTestingConnection(true)
setTestResult(null)
try {
const result = await settingsApi.testConservatore(selectedTenantId)
setTestResult(result)
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })
?.response?.data?.detail
setTestResult({
success: false,
message: msg ?? 'Errore durante il test di connessione',
latency_ms: null,
provider_info: null,
})
} finally {
setTestingConnection(false)
}
}
/* ── Cambio modalita' archiviazione ── */
const handleModeToggle = (newMode: ArchivalMode) => {
if (newMode === 'production' && archivalMode === 'mock') {
@@ -1073,21 +1118,24 @@ export function SettingsPage() {
archival_mode: archivalMode,
conservatore_id: conservatoreId || undefined,
conservatore_endpoint: conservatoreEndpoint || undefined,
conservatore_tenant_slug: conservatoreTenantSlug || undefined,
archival_notes: archivalNotes || undefined,
}
if (conservatoreUsername) payload.conservatore_username = conservatoreUsername
if (conservatorePassword) payload.conservatore_password = conservatorePassword
const updated = await settingsApi.update(payload)
const updated = await settingsApi.update(payload, selectedTenantId)
setArchivalSettings(updated)
setArchivalMode(updated.archival_mode)
setConservatoreId(updated.conservatore_id)
setConservatoreEndpoint(updated.conservatore_endpoint ?? '')
setConservatoreTenantSlug(updated.conservatore_tenant_slug ?? '')
setArchivalNotes(updated.archival_notes ?? '')
setConservatoreUsername('')
setConservatorePassword('')
setShowProductionConfirm(false)
setTestResult(null)
toast.success(
updated.archival_mode === 'production'
@@ -1231,6 +1279,41 @@ export function SettingsPage() {
{archivalExpanded && (
<div className="mt-5 space-y-5">
{/* ── Selector tenant (solo super_admin) ── */}
{isSuperAdmin && tenantList.length > 0 && (
<div className="rounded-lg bg-purple-50 border border-purple-200 p-3 space-y-2">
<div className="flex items-center gap-2">
<User className="h-3.5 w-3.5 text-purple-600" />
<span className="text-xs font-semibold text-purple-800 uppercase tracking-wide">
Gestione per tenant (Super Admin)
</span>
</div>
<select
className="w-full rounded-md border border-purple-300 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
value={selectedTenantId ?? ''}
onChange={(e) => {
const val = e.target.value
setSelectedTenantId(val || undefined)
setShowProductionConfirm(false)
}}
>
<option value=""> Il tuo tenant (super_admin) </option>
{tenantList.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug}){!t.is_active ? ' — SOSPESO' : ''}
</option>
))}
</select>
{selectedTenantId && (
<p className="text-xs text-purple-700">
Stai modificando le impostazioni del tenant selezionato.
Le modifiche vengono salvate separatamente per ogni tenant.
</p>
)}
</div>
)}
{loadingArchival ? (
<p className="text-sm text-gray-500 py-4 text-center">
Caricamento impostazioni...
@@ -1334,7 +1417,21 @@ export function SettingsPage() {
id="conservatore_endpoint"
value={conservatoreEndpoint}
onChange={(e) => setConservatoreEndpoint(e.target.value)}
placeholder="https://conservatore.provider.it/api/v1"
placeholder="https://api.aeterna.idrainformatica.it"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="conservatore_tenant_slug">
Tenant Slug
<span className="ml-2 text-xs font-normal text-gray-400">(es. Aeterna: slug organizzazione)</span>
</Label>
<Input
id="conservatore_tenant_slug"
value={conservatoreTenantSlug}
onChange={(e) => setConservatoreTenantSlug(e.target.value)}
placeholder="es. pechub"
autoComplete="off"
/>
</div>
@@ -1398,6 +1495,7 @@ export function SettingsPage() {
/>
</div>
{/* ── Riepilogo configurazione salvata ── */}
{archivalSettings && (
<div className={`rounded-lg p-3 text-xs border ${
archivalSettings.archival_mode === 'production'
@@ -1411,6 +1509,9 @@ export function SettingsPage() {
{archivalSettings.conservatore_endpoint && (
<li>Endpoint: <strong className="font-mono text-xs break-all">{archivalSettings.conservatore_endpoint}</strong></li>
)}
{archivalSettings.conservatore_tenant_slug && (
<li>Tenant Slug: <strong>{archivalSettings.conservatore_tenant_slug}</strong></li>
)}
<li>Credenziali: {
archivalSettings.conservatore_username_configured && archivalSettings.conservatore_password_configured
? <strong className="text-green-700">Configurate</strong>
@@ -1420,7 +1521,51 @@ export function SettingsPage() {
</div>
)}
<div className="flex justify-end pt-1">
{/* ── Risultato test connessione ── */}
{testResult && (
<div className={`rounded-lg p-3 text-xs border flex items-start gap-2 ${
testResult.success
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-red-50 border-red-200 text-red-800'
}`}>
{testResult.success
? <CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0 mt-0.5" />
: <AlertTriangle className="h-4 w-4 text-red-600 flex-shrink-0 mt-0.5" />
}
<div className="flex-1 space-y-1">
<p className="font-medium">{testResult.success ? 'Connessione riuscita' : 'Connessione fallita'}</p>
<p>{testResult.message}</p>
{testResult.latency_ms !== null && (
<p className="text-gray-500">Latenza: {testResult.latency_ms} ms</p>
)}
{testResult.provider_info && testResult.success && (
<div className="mt-1 text-gray-600 space-y-0.5">
{Object.entries(testResult.provider_info).map(([k, v]) => (
<div key={k}>
<span className="text-gray-400">{k}: </span>
<span>{String(v)}</span>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* ── Azioni ── */}
<div className="flex items-center justify-between pt-1">
<Button
variant="outline"
onClick={handleTestConnection}
disabled={testingConnection || archivalMode !== 'production'}
className="flex items-center gap-1.5 text-blue-700 border-blue-300 hover:bg-blue-50 disabled:opacity-50"
>
{testingConnection
? <Loader2 className="h-4 w-4 animate-spin" />
: <RefreshCw className="h-4 w-4" />
}
{testingConnection ? 'Test in corso...' : 'Testa connessione'}
</Button>
<Button
onClick={handleSaveArchival}
disabled={savingArchival}
+11
View File
@@ -102,6 +102,8 @@ export interface UserUpdateRequest {
export type MailboxStatus = 'active' | 'paused' | 'error' | 'deleted'
export type MailboxProtocol = 'pec_it' | 'rem_eu'
export interface MailboxResponse {
id: string
tenant_id: string
@@ -114,6 +116,9 @@ export interface MailboxResponse {
smtp_host: string
smtp_port: number
smtp_use_tls: boolean
// Protocollo (Feature N8 REM europea)
protocol_type: MailboxProtocol
rem_provider: string | null
status: MailboxStatus
last_sync_at: string | null
last_sync_uid: number | null
@@ -135,6 +140,9 @@ export interface MailboxCreateRequest {
email_address: string
display_name?: string
provider?: string
// Protocollo (Feature N8 REM europea)
protocol_type?: MailboxProtocol
rem_provider?: string
imap_host: string
imap_port: number
imap_user: string
@@ -151,6 +159,9 @@ export interface MailboxUpdateRequest {
display_name?: string
provider?: string
status?: 'active' | 'paused'
// Protocollo (Feature N8)
protocol_type?: MailboxProtocol
rem_provider?: string | null
imap_host?: string
imap_port?: number
imap_user?: string
+578 -11
View File
@@ -8,9 +8,11 @@ Modalità mock (default in sviluppo):
- Utile per sviluppo, test e demo
Modalità produzione:
- Esegue chiamate HTTP reali all'endpoint AgID del conservatore configurato
- Usa le credenziali cifrate recuperate dalle impostazioni del tenant
- Autenticazione HTTP Basic (standard AgID per versamenti SIP)
- Supporta Aeterna (api.aeterna.idrainformatica.it) con autenticazione JWT
- Costruisce pacchetti SIP in formato BagIt RFC 8493 (auto-rilevato da Aeterna)
- Upload multipart/form-data a POST /api/v1/ingest/upload
- Polling status via GET /api/v1/ingest/{package_id}/status
- DIP via POST /api/v1/packages/{package_id}/disseminate
Come switchare da mock a produzione:
L'admin del tenant configura la modalità dalla pagina Impostazioni del
@@ -20,7 +22,7 @@ Come switchare da mock a produzione:
Interfaccia pubblica (stessa per entrambe le modalità):
client = ConservatoreClient.from_tenant_credentials(creds)
result = await client.upload_versamento(sip_path, sip_bytes)
result = await client.upload_versamento(sip_path, sip_bytes, tenant_id)
status = await client.get_versamento_status(versamento_id)
dip = await client.get_dip(versamento_id)
"""
@@ -28,7 +30,10 @@ Interfaccia pubblica (stessa per entrambe le modalità):
from __future__ import annotations
import hashlib
import io
import time
import uuid
import zipfile
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
@@ -152,15 +157,548 @@ class MockConservatoreClient(_BaseConservatoreClient):
)
# ─── Implementazione PRODUZIONE ───────────────────────────────────────────────
# ─── BagIt SIP Builder ────────────────────────────────────────────────────────
def build_bagit_sip(
eml_bytes: bytes,
message_id: str,
subject: str | None = None,
from_address: str | None = None,
to_addresses: list[str] | None = None,
received_at: str | None = None,
) -> bytes:
"""
Costruisce un pacchetto SIP in formato BagIt RFC 8493 come ZIP in memoria.
La struttura generata è:
{bag_name}/
bagit.txt BagIt-Version + Tag-File-Character-Encoding
bag-info.txt Metadati descrittivi del messaggio PEC
manifest-sha256.txt Checksum SHA-256 del file EML
data/
{message_id}.eml Il messaggio PEC grezzo
BagIt è accettato da Aeterna e rilevato automaticamente dalla pipeline
di ingest (presenza di bagit.txt nella root del bag).
Args:
eml_bytes: Contenuto grezzo del file .eml
message_id: UUID del messaggio PecHub (usato come nome file)
subject: Oggetto del messaggio (per bag-info.txt)
from_address: Mittente
to_addresses: Destinatari
received_at: Data di ricezione ISO 8601
Returns:
Bytes dello ZIP del pacchetto BagIt
"""
bag_name = f"pechub-pec-{message_id}"
eml_filename = f"{message_id}.eml"
data_path = f"data/{eml_filename}"
eml_sha256 = hashlib.sha256(eml_bytes).hexdigest()
# Contenuto bagit.txt (obbligatorio per BagIt RFC 8493)
bagit_txt = "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n"
# Contenuto bag-info.txt (metadati descrittivi, opzionale ma utile)
bag_info_lines = [
f"Bag-Software-Agent: PecHub Archival Module",
f"Bagging-Date: {datetime.now(UTC).strftime('%Y-%m-%d')}",
f"External-Identifier: {message_id}",
f"Source-Organization: PecHub",
]
if subject:
bag_info_lines.append(f"Description: {subject[:500]}")
if from_address:
bag_info_lines.append(f"Contact-Email: {from_address}")
if to_addresses:
bag_info_lines.append(f"External-Description: PEC a {', '.join(to_addresses[:3])}")
if received_at:
bag_info_lines.append(f"Bag-Group-Identifier: {received_at[:10]}")
bag_info_txt = "\n".join(bag_info_lines) + "\n"
# Contenuto manifest-sha256.txt (checksum file nella directory data/)
manifest_txt = f"{eml_sha256} {data_path}\n"
# Costruzione ZIP in memoria
buf = io.BytesIO()
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"{bag_name}/bagit.txt", bagit_txt)
zf.writestr(f"{bag_name}/bag-info.txt", bag_info_txt)
zf.writestr(f"{bag_name}/manifest-sha256.txt", manifest_txt)
zf.writestr(f"{bag_name}/{data_path}", eml_bytes)
return buf.getvalue()
def build_bagit_sip_complete(
eml_bytes: bytes,
message_id: str,
subject: str | None = None,
from_address: str | None = None,
to_addresses: list[str] | None = None,
received_at: str | None = None,
attachments: list[tuple[str, bytes]] | None = None,
receipts: list[tuple[str, bytes]] | None = None,
) -> bytes:
"""
Costruisce un pacchetto SIP BagIt completo con EML principale, allegati e ricevute.
Struttura generata:
{bag_name}/
bagit.txt
bag-info.txt
manifest-sha256.txt
data/
{message_id}.eml EML messaggio originale
allegati/
{filename} Allegati (da MinIO)
ricevute/
{receipt_id}.eml EML ricevute accettazione/consegna
Args:
eml_bytes: EML grezzo del messaggio principale
message_id: UUID del messaggio PecHub
subject: Oggetto (per bag-info.txt)
from_address: Mittente
to_addresses: Destinatari
received_at: Data ISO 8601
attachments: Lista di (filename, bytes) per ogni allegato
receipts: Lista di (receipt_id, eml_bytes) per ogni ricevuta PEC
Returns:
Bytes dello ZIP BagIt
"""
bag_name = f"pechub-pec-{message_id}"
eml_filename = f"{message_id}.eml"
eml_data_path = f"data/{eml_filename}"
eml_sha256 = hashlib.sha256(eml_bytes).hexdigest()
bagit_txt = "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n"
bag_info_lines = [
"Bag-Software-Agent: PecHub Archival Module",
f"Bagging-Date: {datetime.now(UTC).strftime('%Y-%m-%d')}",
f"External-Identifier: {message_id}",
"Source-Organization: PecHub",
]
if subject:
bag_info_lines.append(f"Description: {subject[:500]}")
if from_address:
bag_info_lines.append(f"Contact-Email: {from_address}")
if to_addresses:
bag_info_lines.append(f"External-Description: PEC a {', '.join(to_addresses[:3])}")
if received_at:
bag_info_lines.append(f"Bag-Group-Identifier: {received_at[:10]}")
n_allegati = len(attachments) if attachments else 0
n_ricevute = len(receipts) if receipts else 0
bag_info_lines.append(f"Payload-Oxum: allegati={n_allegati}, ricevute={n_ricevute}")
bag_info_txt = "\n".join(bag_info_lines) + "\n"
# Costruzione manifest: elenca tutti i file in data/ con i loro checksum
manifest_lines = [f"{eml_sha256} {eml_data_path}"]
buf = io.BytesIO()
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
# EML principale
zf.writestr(f"{bag_name}/{eml_data_path}", eml_bytes)
# Allegati
if attachments:
for filename, att_bytes in attachments:
att_path = f"data/allegati/{filename}"
att_sha256 = hashlib.sha256(att_bytes).hexdigest()
manifest_lines.append(f"{att_sha256} {att_path}")
zf.writestr(f"{bag_name}/{att_path}", att_bytes)
# Ricevute PEC (accettazione, avvenuta consegna)
if receipts:
for receipt_id, receipt_eml_bytes in receipts:
rec_path = f"data/ricevute/{receipt_id}.eml"
rec_sha256 = hashlib.sha256(receipt_eml_bytes).hexdigest()
manifest_lines.append(f"{rec_sha256} {rec_path}")
zf.writestr(f"{bag_name}/{rec_path}", receipt_eml_bytes)
# File di metadati (aggiunti dopo i dati per avere il manifest completo)
manifest_txt = "\n".join(manifest_lines) + "\n"
zf.writestr(f"{bag_name}/bagit.txt", bagit_txt)
zf.writestr(f"{bag_name}/bag-info.txt", bag_info_txt)
zf.writestr(f"{bag_name}/manifest-sha256.txt", manifest_txt)
return buf.getvalue()
# ─── Implementazione AETERNA (JWT + E-ARK BagIt) ─────────────────────────────
class AeternaConservatoreClient(_BaseConservatoreClient):
"""
Client HTTP per Aeterna piattaforma di conservazione digitale E-ARK.
Provider: aeterna.idrainformatica.it
Endpoint: https://api.aeterna.idrainformatica.it
Standard: E-ARK CSIP 2.1.0, BagIt RFC 8493, PREMIS 3.0, METS 1.12
Autenticazione:
POST /api/v1/auth/login {email, password, tenant_slug}
Restituisce JWT Bearer (access_token, expires_in=3600, refresh_token)
Ingest (SIP upload):
POST /api/v1/ingest/upload multipart/form-data
Campi: file (ZIP BagIt), title (str obbligatorio)
Risposta 202: {package_id, pid, status, task_id}
Status polling:
GET /api/v1/ingest/{package_id}/status
status: UPLOADED | VALIDATING | PROCESSING | ACTIVE | FAILED | REJECTED
DIP (disseminazione):
POST /api/v1/packages/{package_id}/disseminate {note: null}
GET /api/v1/packages/{package_id}/dip stato DIP
GET /api/v1/packages/{package_id}/dip/download stream ZIP DIP
Il pacchetto SIP viene costruito in formato BagIt RFC 8493 (build_bagit_sip).
Il formato è auto-rilevato da Aeterna durante l'ingest.
"""
# Mapping stati Aeterna → stati PecHub
_STATUS_MAP = {
"UPLOADED": "uploading",
"VALIDATING": "uploading",
"PROCESSING": "uploading",
"INGESTING": "uploading",
"ACTIVE": "confirmed",
"FAILED": "failed",
"REJECTED": "rejected",
"DELETED": "rejected",
}
def __init__(
self,
endpoint: str,
username: str,
password: str,
tenant_slug: str,
conservatore_id: str = "aeterna",
timeout_seconds: int = 120,
) -> None:
self.endpoint = endpoint.rstrip("/")
self.username = username
self.password = password
self.tenant_slug = tenant_slug
self.conservatore_id = conservatore_id
self.timeout_seconds = timeout_seconds
self._access_token: str | None = None
self._token_expires_at: float = 0.0
# ── Autenticazione JWT ─────────────────────────────────────────────────────
async def _login(self) -> str:
"""
Autentica su Aeterna e restituisce un access_token JWT.
Endpoint: POST /api/v1/auth/login
Body: {email, password, tenant_slug}
"""
try:
import httpx
except ImportError:
raise RuntimeError("httpx non installato nel worker")
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{self.endpoint}/api/v1/auth/login",
json={
"email": self.username,
"password": self.password,
"tenant_slug": self.tenant_slug,
},
)
resp.raise_for_status()
data = resp.json()
token = data["access_token"]
expires_in = int(data.get("expires_in", 3600))
# Rinnova con 60 secondi di anticipo
self._access_token = token
self._token_expires_at = time.monotonic() + expires_in - 60
return token
async def _get_token(self) -> str:
"""Restituisce il token corrente, eseguendo login se scaduto."""
if self._access_token and time.monotonic() < self._token_expires_at:
return self._access_token
return await self._login()
async def _auth_headers(self) -> dict[str, str]:
token = await self._get_token()
return {"Authorization": f"Bearer {token}"}
# ── Ingest SIP ────────────────────────────────────────────────────────────
async def upload_versamento(
self,
sip_path: str,
sip_bytes: bytes,
tenant_id: uuid.UUID,
*,
subject: str | None = None,
from_address: str | None = None,
to_addresses: list[str] | None = None,
received_at: str | None = None,
message_id: str | None = None,
) -> VersamentoResult:
"""
Costruisce un BagIt SIP e lo carica su Aeterna.
Endpoint: POST /api/v1/ingest/upload (multipart/form-data)
Risposta: 202 Accepted con package_id da usare per polling.
Il versamento_id restituito è il package_id di Aeterna (UUID).
"""
try:
import httpx
except ImportError:
return VersamentoResult(
success=False,
message="httpx non installato nel worker.",
)
try:
headers = await self._auth_headers()
# Se sip_bytes è già uno ZIP BagIt lo usiamo direttamente,
# altrimenti costruiamo un nuovo BagIt dal contenuto EML.
# Convenzione: se sip_path termina con .eml, il contenuto
# è un singolo EML grezzo → wrapping BagIt automatico.
msg_id = message_id or str(uuid.uuid4())
if sip_path.endswith(".eml") or not sip_path.endswith(".zip"):
zip_bytes = build_bagit_sip(
eml_bytes=sip_bytes,
message_id=msg_id,
subject=subject,
from_address=from_address,
to_addresses=to_addresses,
received_at=received_at,
)
zip_filename = f"pechub-pec-{msg_id}.zip"
else:
zip_bytes = sip_bytes
zip_filename = sip_path.split("/")[-1] or "sip.zip"
title = subject or f"PEC {msg_id}"
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
response = await client.post(
f"{self.endpoint}/api/v1/ingest/upload",
headers=headers,
files={"file": (zip_filename, zip_bytes, "application/zip")},
data={
"title": title[:500],
"description": f"Messaggio PEC archiviato da PecHub (tenant {tenant_id})",
"creator": "PecHub Archival Module",
},
)
if response.status_code in (200, 201, 202):
data = response.json()
package_id = data.get("package_id") or data.get("id") or str(uuid.uuid4())
return VersamentoResult(
success=True,
versamento_id=str(package_id),
message=f"Ingest avviato su Aeterna (package_id={package_id})",
raw_response=data,
)
else:
return VersamentoResult(
success=False,
message=f"Aeterna ha risposto HTTP {response.status_code}: {response.text[:500]}",
raw_response={"status_code": response.status_code, "body": response.text[:500]},
)
except Exception as e:
return VersamentoResult(
success=False,
message=f"Errore di connessione ad Aeterna: {e}",
)
# ── Polling status ────────────────────────────────────────────────────────
async def get_versamento_status(
self, versamento_id: str
) -> VersamentoStatus:
"""
Polling dello stato di ingest.
Endpoint: GET /api/v1/ingest/{package_id}/status
Stati Aeterna: UPLOADED, VALIDATING, PROCESSING, ACTIVE, FAILED, REJECTED
"""
try:
import httpx
except ImportError:
return VersamentoStatus(
versamento_id=versamento_id,
status="unknown",
message="httpx non installato",
)
try:
headers = await self._auth_headers()
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
f"{self.endpoint}/api/v1/ingest/{versamento_id}/status",
headers=headers,
)
if response.status_code == 200:
data = response.json()
aeterna_status = data.get("status", "UNKNOWN").upper()
pechub_status = self._STATUS_MAP.get(aeterna_status, "uploading")
is_accepted = aeterna_status == "ACTIVE"
return VersamentoStatus(
versamento_id=versamento_id,
status=pechub_status,
message=data.get("error_message") or f"Aeterna status: {aeterna_status}",
rdv_available=is_accepted,
raw_response=data,
)
elif response.status_code == 404:
return VersamentoStatus(
versamento_id=versamento_id,
status="failed",
message="Package non trovato su Aeterna",
)
else:
return VersamentoStatus(
versamento_id=versamento_id,
status="error",
message=f"HTTP {response.status_code}: {response.text[:200]}",
)
except Exception as e:
return VersamentoStatus(
versamento_id=versamento_id,
status="error",
message=f"Errore connessione: {e}",
)
# ── DIP (Dissemination Information Package) ───────────────────────────────
async def get_dip(self, versamento_id: str) -> DipResult:
"""
Richiede generazione DIP su Aeterna e restituisce i dettagli.
Endpoint: POST /api/v1/packages/{package_id}/disseminate
Poi poll: GET /api/v1/packages/{package_id}/dip
Download: GET /api/v1/packages/{package_id}/dip/download
"""
try:
import httpx
except ImportError:
return DipResult(success=False, message="httpx non installato")
try:
headers = await self._auth_headers()
# 1. Richiede generazione DIP
async with httpx.AsyncClient(timeout=60) as client:
resp_disseminate = await client.post(
f"{self.endpoint}/api/v1/packages/{versamento_id}/disseminate",
headers=headers,
json={"note": "Generato da PecHub Archival Module"},
)
if resp_disseminate.status_code not in (200, 201, 202):
return DipResult(
success=False,
message=f"Errore avvio DIP HTTP {resp_disseminate.status_code}: {resp_disseminate.text[:200]}",
)
dip_data = resp_disseminate.json()
dip_id = dip_data.get("id", "")
download_url = (
f"{self.endpoint}/api/v1/packages/{versamento_id}/dip/download"
if dip_data.get("is_available") else None
)
return DipResult(
success=True,
dip_id=str(dip_id),
download_url=download_url,
message="DIP richiesto su Aeterna" if not download_url else "DIP disponibile",
)
except Exception as e:
return DipResult(success=False, message=f"Errore connessione: {e}")
# ── Test connessione ──────────────────────────────────────────────────────
async def test_connection(self) -> dict[str, Any]:
"""
Verifica la connessione ad Aeterna eseguendo login e chiamando /me.
Usato dall'endpoint backend POST /settings/test-conservatore.
Restituisce {success, message, latency_ms, provider_info}.
"""
try:
import httpx
except ImportError:
return {"success": False, "message": "httpx non installato", "latency_ms": None}
t_start = time.monotonic()
try:
token = await self._login()
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self.endpoint}/api/v1/auth/me",
headers=headers,
)
latency_ms = int((time.monotonic() - t_start) * 1000)
if resp.status_code == 200:
me = resp.json()
return {
"success": True,
"message": f"Connessione ad Aeterna riuscita (utente: {me.get('email', '?')})",
"latency_ms": latency_ms,
"provider_info": {
"platform": "Aeterna",
"tenant_slug": self.tenant_slug,
"user_id": me.get("id"),
"email": me.get("email"),
"permissions": me.get("permissions", []),
},
}
else:
return {
"success": False,
"message": f"Login riuscito ma /me ha risposto HTTP {resp.status_code}",
"latency_ms": latency_ms,
}
except Exception as e:
latency_ms = int((time.monotonic() - t_start) * 1000)
return {
"success": False,
"message": f"Errore connessione ad Aeterna: {e}",
"latency_ms": latency_ms,
}
# ─── Implementazione PRODUZIONE generica (HTTP Basic) ────────────────────────
class ProductionConservatoreClient(_BaseConservatoreClient):
"""
Client HTTP reale per conservatore AgID.
Client HTTP generico per conservatori AgID con API proprietaria.
Autenticazione: HTTP Basic (standard AgID CNIPA).
Autenticazione: HTTP Basic (standard AgID CNIPA vecchio stile).
Formato SIP: UNI SInCRO 11386:2023 (pacchetto ZIP con indice XML).
NON compatibile con Aeterna (che usa JWT + BagIt).
Usa AeternaConservatoreClient per il provider Aeterna.
L'URL endpoint e le credenziali arrivano dalla tabella tenant_settings
(decifrate a runtime dal TenantSettingsService).
"""
@@ -187,7 +725,7 @@ class ProductionConservatoreClient(_BaseConservatoreClient):
) -> VersamentoResult:
"""
POST {endpoint}/versamento
Content-Type: application/octet-stream (o multipart/form-data per alcuni provider)
Content-Type: application/octet-stream
Authorization: Basic base64(user:pass)
"""
try:
@@ -333,12 +871,17 @@ class ProductionConservatoreClient(_BaseConservatoreClient):
class ConservatoreClient:
"""
Factory che istanzia il client corretto in base alla modalità.
Factory che istanzia il client corretto in base alla modalità e al provider.
Utilizzo dal worker:
creds = await tenant_settings_service.get_conservatore_credentials(tenant_id)
client = ConservatoreClient.from_tenant_credentials(creds)
result = await client.upload_versamento(sip_path, sip_bytes, tenant_id)
Provider riconosciuti:
conservatore_id == "aeterna" AeternaConservatoreClient (JWT + BagIt)
conservatore_id == altro ProductionConservatoreClient (HTTP Basic)
mode == "mock" MockConservatoreClient
"""
@staticmethod
@@ -348,7 +891,8 @@ class ConservatoreClient:
Args:
creds: dizionario da TenantSettingsService.get_conservatore_credentials()
con chiavi: mode, conservatore_id, endpoint, username, password
con chiavi: mode, conservatore_id, endpoint, tenant_slug,
username, password
"""
mode = creds.get("mode", "mock")
@@ -356,6 +900,7 @@ class ConservatoreClient:
endpoint = creds.get("endpoint")
username = creds.get("username")
password = creds.get("password")
conservatore_id = creds.get("conservatore_id", "production")
if not endpoint:
raise ValueError(
@@ -368,11 +913,33 @@ class ConservatoreClient:
"Configurare username e password nelle impostazioni del tenant."
)
# Aeterna usa JWT + BagIt: riconosciuto da conservatore_id o dall'URL
is_aeterna = (
conservatore_id.lower() == "aeterna"
or "aeterna" in (endpoint or "").lower()
or "idrainformatica" in (endpoint or "").lower()
)
if is_aeterna:
tenant_slug = creds.get("tenant_slug")
if not tenant_slug:
raise ValueError(
"Provider Aeterna richiede il Tenant Slug. "
"Configurare conservatore_tenant_slug nelle impostazioni del tenant."
)
return AeternaConservatoreClient(
endpoint=endpoint,
username=username,
password=password,
tenant_slug=tenant_slug,
conservatore_id=conservatore_id,
)
return ProductionConservatoreClient(
endpoint=endpoint,
username=username,
password=password,
conservatore_id=creds.get("conservatore_id", "production"),
conservatore_id=conservatore_id,
)
# Default: modalità mock (sicura per sviluppo)
+48 -12
View File
@@ -39,7 +39,8 @@ from app.jobs.dispatch_notification import evaluate_and_enqueue_notifications
from app.jobs.index_message import index_message
from app.models import Attachment, Mailbox, Message
from app.parsers.eml_parser import parse_eml
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message, detect_protocol
from app.parsers.rem_parser import classify_rem_message
from app.storage.minio_client import upload_attachment, upload_eml
logger = logging.getLogger(__name__)
@@ -543,15 +544,47 @@ async def _save_message(
logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip")
return False
# ── Classificazione PEC da header (veloce, senza body) ───────────────────
# ── Classificazione tipo messaggio da header (veloce, senza body) ────────
# La classificazione avviene PRIMA del parsing completo perche' il parser
# deve sapere se il messaggio e' una ricevuta per evitare di sovrascrivere
# il body_text (testo della ricevuta) con il contenuto di postacert.eml.
#
# Strategia dual-protocol (Feature N8 REM europea):
# 1. Rileva automaticamente il protocollo dagli header del messaggio.
# 2. Se header X-REM-* presenti: usa classify_rem_message (ETSI EN 319 532-4)
# 3. Se header PEC italiani (X-Ricevuta/X-TipoRicevuta): usa classify_pec_message
# 4. Default fallback: PEC italiana
#
# Il protocollo configurato sulla casella (mailbox.protocol_type) viene
# usato come hint, ma il rilevamento automatico degli header ha priorita'.
quick_msg = email.message_from_bytes(raw_eml)
mailbox_protocol = getattr(mailbox, "protocol_type", "pec_it") or "pec_it"
detected_protocol = detect_protocol(quick_msg)
# Il rilevamento automatico ha priorita': una casella PEC-IT che riceve un
# messaggio REM da partner europeo viene classificata correttamente.
_protocol_type = detected_protocol if detected_protocol == "rem_eu" else mailbox_protocol
if _protocol_type == "rem_eu":
rem_class = classify_rem_message(quick_msg)
_pec_type = rem_class.pec_type
_is_receipt = rem_class.is_receipt
_riferimento_message_id = rem_class.riferimento_message_id
_rem_evidence_type = rem_class.rem_evidence_type
logger.debug(
f"[{mailbox.email_address}] REM UID={uid}: "
f"evidence={_rem_evidence_type!r} → pec_type={_pec_type!r}"
)
else:
pec_class = classify_pec_message(quick_msg)
_pec_type = pec_class.pec_type
_is_receipt = pec_class.is_receipt
_riferimento_message_id = pec_class.riferimento_message_id
_rem_evidence_type = None
_protocol_type = "pec_it"
# ── Parsing completo EML (con is_receipt per proteggere il body) ──────────
parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt)
parsed = parse_eml(raw_eml, is_receipt=_is_receipt)
received_at = datetime.now(UTC)
# ── Dedup outbound: upsert sul record send_pec invece di creare duplicato ─
@@ -605,18 +638,18 @@ async def _save_message(
# Solo per messaggi inbound che sono ricevute PEC (non per posta inviata)
parent_message_id: uuid.UUID | None = None
if direction == "inbound" and pec_class.is_receipt and pec_class.riferimento_message_id:
if direction == "inbound" and _is_receipt and _riferimento_message_id:
try:
parent_message_id = await _apply_outbound_state_machine(
riferimento_message_id=pec_class.riferimento_message_id,
pec_type=pec_class.pec_type,
riferimento_message_id=_riferimento_message_id,
pec_type=_pec_type,
tenant_id=mailbox.tenant_id,
db=db,
)
except Exception as bind_err:
logger.error(
f"[{mailbox.email_address}] [receipt-binding] Errore aggiornamento stato "
f"outbound per ricevuta UID={uid} tipo={pec_class.pec_type!r}: {bind_err}",
f"outbound per ricevuta UID={uid} tipo={_pec_type!r}: {bind_err}",
exc_info=True,
)
# Non interrompere il salvataggio della ricevuta: il record viene
@@ -648,7 +681,9 @@ async def _save_message(
imap_folder=imap_folder,
direction=direction,
state=state,
pec_type=pec_class.pec_type,
pec_type=_pec_type,
protocol_type=_protocol_type,
rem_evidence_type=_rem_evidence_type,
subject=parsed.subject,
from_address=parsed.from_address,
to_addresses=parsed.to_addresses if parsed.to_addresses else None,
@@ -687,7 +722,7 @@ async def _save_message(
"from_address": message.from_address or "",
"pec_type": message.pec_type,
"direction": direction,
"is_receipt": pec_class.is_receipt,
"is_receipt": _is_receipt,
"received_at": received_at.isoformat(),
}
await redis_client.publish(f"ws:tenant:{mailbox.tenant_id}", json.dumps(event))
@@ -696,7 +731,7 @@ async def _save_message(
logger.info(
f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} folder={imap_folder!r} "
f"direction={direction!r} pec_type={pec_class.pec_type!r} "
f"direction={direction!r} pec_type={_pec_type!r} protocol={_protocol_type!r} "
f"subject={message.subject!r} allegati={len(parsed.attachments)}"
)
@@ -719,7 +754,8 @@ async def _save_message(
# ── Regole di smistamento automatico (Feature 2) ──────────────────────────
# Solo per messaggi inbound posta_certificata (non ricevute di sistema).
if direction == "inbound" and pec_class.pec_type == "posta_certificata":
# Valido anche per messaggi REM (REMDispatch e' equivalente a posta_certificata).
if direction == "inbound" and _pec_type == "posta_certificata":
try:
await redis_client.enqueue_job("apply_routing_rules", str(message.id))
except Exception as e:
@@ -728,7 +764,7 @@ async def _save_message(
# ── Auto-save mittente nella rubrica (Feature 6) ──────────────────────────
# Per messaggi inbound di tipo posta_certificata, salva automaticamente
# il mittente nella rubrica pec_contacts del tenant (upsert idempotente).
if direction == "inbound" and pec_class.pec_type == "posta_certificata" and message.from_address:
if direction == "inbound" and _pec_type == "posta_certificata" and message.from_address:
try:
from sqlalchemy import text as _text
await db.execute(
+358
View File
@@ -0,0 +1,358 @@
"""
Job arq: run_conservation conservazione sostitutiva giornaliera.
Eseguito ogni giorno alle 18:00 (ora Italia, 16:00 UTC) tramite arq cron.
Flusso di esecuzione:
1. Trova tutti i tenant che hanno messaggi con is_pending_conservation=TRUE
e is_conserved=FALSE
2. Per ogni tenant, legge la configurazione conservatore da tenant_settings
(decifra credenziali se in modalità produzione)
3. Per ogni messaggio da conservare:
a. Carica il messaggio con i suoi allegati dal DB
b. Carica le ricevute associate (accettazione, avvenuta_consegna)
c. Scarica EML principale da MinIO
d. Scarica ogni allegato da MinIO
e. Scarica EML di ogni ricevuta da MinIO (se disponibile)
f. Costruisce pacchetto BagIt SIP completo
g. Invia al conservatore
h. Su successo: imposta is_conserved=TRUE, conserved_at=NOW(),
is_pending_conservation=FALSE
i. Su errore: logga l'errore e lascia is_pending_conservation=TRUE
(verrà ritentato al prossimo run giornaliero)
Gestione messaggi senza EML in MinIO:
Se raw_eml_path è NULL o il download fallisce, viene generato un EML
sintetico dai metadati del messaggio. La conservazione procede comunque.
Idempotenza:
Il job è idempotente: se eseguito più volte, salta i messaggi già conservati
(is_conserved=TRUE) perché il filtro WHERE esclude già quei record.
"""
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.orm import selectinload
from app.archival.conservatore_client import (
ConservatoreClient,
build_bagit_sip_complete,
)
from app.database import AsyncSessionLocal
from app.models import Attachment, Message, TenantSettings
from app.security import decrypt_credential
from app.storage.minio_client import download_attachment as minio_download
logger = logging.getLogger(__name__)
# ─── Costanti ─────────────────────────────────────────────────────────────────
# Tipi di messaggio considerati "ricevute" da includere nel SIP
RECEIPT_PEC_TYPES = {"accettazione", "avvenuta_consegna"}
# Massimo messaggi processati per run (evita timeout su run iniziali con backlog)
MAX_MESSAGES_PER_RUN = 200
# ─── Helper: download EML da MinIO ────────────────────────────────────────────
async def _download_eml(raw_eml_path: str | None, msg_id: str, subject: str | None,
from_address: str | None, to_addresses: list[str] | None,
received_at: datetime | None) -> bytes:
"""
Tenta di scaricare l'EML da MinIO. Se non disponibile, genera un EML sintetico.
"""
if raw_eml_path:
try:
data = await minio_download(raw_eml_path)
logger.debug(f"[conservation] EML scaricato da MinIO: {raw_eml_path} ({len(data)} bytes)")
return data
except Exception as e:
logger.warning(f"[conservation] Download EML fallito ({raw_eml_path}): {e} uso EML sintetico")
# EML sintetico dai metadati
recv_str = received_at.isoformat() if received_at else datetime.now(UTC).isoformat()
to_str = ", ".join(to_addresses) if to_addresses else "destinatario@pec.it"
synthetic_eml = (
f"From: {from_address or 'mittente@pec.it'}\r\n"
f"To: {to_str}\r\n"
f"Subject: {subject or 'Messaggio PEC'}\r\n"
f"Date: {recv_str}\r\n"
f"Message-ID: <{msg_id}@pechub.synthetic>\r\n"
f"Content-Type: text/plain; charset=UTF-8\r\n"
f"MIME-Version: 1.0\r\n"
f"\r\n"
f"[EML sintetico generato da PecHub EML originale non disponibile]\r\n"
f"ID messaggio PecHub: {msg_id}\r\n"
f"Data conservazione: {datetime.now(UTC).isoformat()}\r\n"
)
logger.warning(f"[conservation] EML sintetico generato per messaggio {msg_id}")
return synthetic_eml.encode("utf-8")
# ─── Helper: credenziali conservatore dal DB ──────────────────────────────────
async def _get_conservatore_creds(db, tenant_id: uuid.UUID) -> dict:
"""
Legge le impostazioni conservatore del tenant dal DB e decifra le credenziali.
Restituisce un dict compatibile con ConservatoreClient.from_tenant_credentials().
"""
result = await db.execute(
select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
)
settings = result.scalar_one_or_none()
if settings is None:
# Nessuna configurazione: usa mock
return {"mode": "mock", "conservatore_id": "mock"}
username = None
password = None
try:
if settings.conservatore_username_enc:
username = decrypt_credential(settings.conservatore_username_enc)
if settings.conservatore_password_enc:
password = decrypt_credential(settings.conservatore_password_enc)
except ValueError as e:
logger.error(f"[conservation] Decifratura credenziali fallita per tenant {tenant_id}: {e}")
return {
"mode": settings.archival_mode,
"conservatore_id": settings.conservatore_id,
"endpoint": settings.conservatore_endpoint,
"tenant_slug": settings.conservatore_tenant_slug,
"username": username,
"password": password,
}
# ─── Job principale ───────────────────────────────────────────────────────────
async def run_conservation(ctx: dict[str, Any]) -> dict:
"""
Job arq: esegue il ciclo di conservazione sostitutiva per tutti i tenant.
Schedulato tramite cron alle 16:00 UTC (18:00 ora Italia).
Returns:
dict con statistiche del run: tenant processati, messaggi conservati,
messaggi falliti.
"""
logger.info("[conservation] Avvio ciclo conservazione sostitutiva")
stats = {
"tenant_count": 0,
"messages_processed": 0,
"messages_conserved": 0,
"messages_failed": 0,
"started_at": datetime.now(UTC).isoformat(),
}
async with AsyncSessionLocal() as db:
# ── 1. Trova tutti i tenant con messaggi da conservare ────────────────
result = await db.execute(
select(Message.tenant_id)
.where(
Message.is_pending_conservation == True, # noqa: E712
Message.is_conserved == False, # noqa: E712
)
.distinct()
)
tenant_ids = [row[0] for row in result.fetchall()]
if not tenant_ids:
logger.info("[conservation] Nessun messaggio da conservare")
stats["finished_at"] = datetime.now(UTC).isoformat()
return stats
logger.info(f"[conservation] Tenant con messaggi pendenti: {len(tenant_ids)}")
stats["tenant_count"] = len(tenant_ids)
# ── 2. Processa ogni tenant ───────────────────────────────────────────
for tenant_id in tenant_ids:
logger.info(f"[conservation] Elaborazione tenant {tenant_id}")
# Leggi credenziali conservatore
try:
creds = await _get_conservatore_creds(db, tenant_id)
client = ConservatoreClient.from_tenant_credentials(creds)
conservatore_mode = creds.get("mode", "mock")
logger.info(
f"[conservation] Tenant {tenant_id}: "
f"modalita={conservatore_mode}, "
f"conservatore={creds.get('conservatore_id', 'mock')}"
)
except Exception as e:
logger.error(
f"[conservation] Impossibile inizializzare client conservatore "
f"per tenant {tenant_id}: {e} tenant saltato"
)
continue
# Carica messaggi da conservare con allegati (eager load)
msgs_result = await db.execute(
select(Message)
.where(
Message.tenant_id == tenant_id,
Message.is_pending_conservation == True, # noqa: E712
Message.is_conserved == False, # noqa: E712
)
.options(selectinload(Message.attachments))
.order_by(Message.received_at.asc().nullsfirst())
.limit(MAX_MESSAGES_PER_RUN)
)
messages = msgs_result.scalars().all()
logger.info(
f"[conservation] Tenant {tenant_id}: "
f"{len(messages)} messaggi da conservare"
)
for msg in messages:
stats["messages_processed"] += 1
msg_id = str(msg.id)
subject = msg.subject or f"PEC {msg_id[:8]}"
try:
# ── a. Scarica EML principale ─────────────────────────────
eml_bytes = await _download_eml(
raw_eml_path=msg.raw_eml_path,
msg_id=msg_id,
subject=msg.subject,
from_address=msg.from_address,
to_addresses=msg.to_addresses,
received_at=msg.received_at,
)
# ── b. Scarica allegati ───────────────────────────────────
attachment_files: list[tuple[str, bytes]] = []
for att in msg.attachments:
try:
att_bytes = await minio_download(att.storage_path)
# Usa il filename originale, sanitizzato per path sicuri
safe_name = att.filename.replace("/", "_").replace("\\", "_")
attachment_files.append((safe_name, att_bytes))
logger.debug(
f"[conservation] Allegato scaricato: "
f"{att.filename} ({len(att_bytes)} bytes)"
)
except Exception as e:
logger.warning(
f"[conservation] Allegato non disponibile "
f"({att.filename}): {e} saltato"
)
# ── c. Carica e scarica ricevute PEC ──────────────────────
receipts_result = await db.execute(
select(Message).where(
Message.parent_message_id == msg.id,
Message.pec_type.in_(list(RECEIPT_PEC_TYPES)),
)
)
receipt_messages = receipts_result.scalars().all()
receipt_files: list[tuple[str, bytes]] = []
for receipt in receipt_messages:
if not receipt.raw_eml_path:
logger.debug(
f"[conservation] Ricevuta {receipt.id} "
f"({receipt.pec_type}) senza EML saltata"
)
continue
try:
rec_bytes = await minio_download(receipt.raw_eml_path)
receipt_files.append((str(receipt.id), rec_bytes))
logger.debug(
f"[conservation] Ricevuta scaricata: "
f"{receipt.pec_type} ({len(rec_bytes)} bytes)"
)
except Exception as e:
logger.warning(
f"[conservation] Download ricevuta {receipt.id} fallito: "
f"{e} saltata"
)
# ── d. Costruisci SIP BagIt completo ──────────────────────
sip_bytes = build_bagit_sip_complete(
eml_bytes=eml_bytes,
message_id=msg_id,
subject=msg.subject,
from_address=msg.from_address,
to_addresses=msg.to_addresses,
received_at=msg.received_at.isoformat() if msg.received_at else None,
attachments=attachment_files if attachment_files else None,
receipts=receipt_files if receipt_files else None,
)
n_att = len(attachment_files)
n_rec = len(receipt_files)
sip_size_kb = len(sip_bytes) // 1024
logger.info(
f"[conservation] SIP costruito per {msg_id[:8]}... "
f"({sip_size_kb} KB, {n_att} allegati, {n_rec} ricevute)"
)
# ── e. Upload al conservatore ─────────────────────────────
sip_filename = f"pechub-pec-{msg_id}.zip"
upload_result = await client.upload_versamento(
sip_path=sip_filename,
sip_bytes=sip_bytes,
tenant_id=tenant_id,
)
if not upload_result.success:
raise RuntimeError(
f"Upload conservatore fallito: {upload_result.message}"
)
versamento_id = upload_result.versamento_id
logger.info(
f"[conservation] Messaggio {msg_id[:8]}... conservato: "
f"versamento_id={versamento_id}"
)
# ── f. Aggiorna DB: messaggio conservato ──────────────────
await db.execute(
update(Message)
.where(Message.id == msg.id)
.values(
is_conserved=True,
conserved_at=datetime.now(UTC),
is_pending_conservation=False,
)
)
await db.commit()
stats["messages_conserved"] += 1
logger.info(
f"[conservation] DB aggiornato: "
f"is_conserved=TRUE, is_pending_conservation=FALSE "
f"per messaggio {msg_id[:8]}..."
)
except Exception as e:
await db.rollback()
stats["messages_failed"] += 1
logger.error(
f"[conservation] ERRORE conservazione messaggio {msg_id}: {e}",
exc_info=True,
)
# Il messaggio resta con is_pending_conservation=TRUE
# e verrà ritentato al prossimo run giornaliero
stats["finished_at"] = datetime.now(UTC).isoformat()
logger.info(
f"[conservation] Ciclo completato: "
f"{stats['messages_conserved']} conservati, "
f"{stats['messages_failed']} falliti "
f"su {stats['messages_processed']} processati"
)
return stats
+13 -3
View File
@@ -19,13 +19,14 @@ import sys
from typing import Any
import redis.asyncio as aioredis
from arq import run_worker
from arq import cron, run_worker
from arq.connections import RedisSettings
from app.config import get_settings
from app.imap.pool import MailboxPool
from app.jobs.apply_routing_rules import apply_routing_rules
from app.jobs.dispatch_notification import dispatch_notification
from app.jobs.run_conservation import run_conservation
from app.jobs.send_pec import send_pec
from app.jobs.sync_mailbox import sync_mailbox
from app.smtp.receipt_watcher import watch_receipt
@@ -133,9 +134,17 @@ def _parse_redis_settings() -> RedisSettings:
class WorkerSettings:
"""Configurazione del worker arq."""
# Funzioni/job registrati
# Funzioni/job registrati (code-driven, on-demand)
functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, apply_routing_rules, health_check]
# Job schedulati (cron)
# run_conservation: ogni giorno alle 16:00 UTC = 18:00 ora Italia (CEST, UTC+2)
# Nota: arq usa sempre UTC per i cron. In orario solare (CET, UTC+1) il job
# viene eseguito alle 17:00 ora Italia differenza accettabile.
cron_jobs = [
cron(run_conservation, hour=16, minute=0),
]
# Callbacks lifecycle
on_startup = on_startup
on_shutdown = on_shutdown
@@ -148,7 +157,8 @@ class WorkerSettings:
# Timeout per ogni job (secondi)
# send_pec può richiedere più tempo su SMTP lenti
job_timeout = 120
# run_conservation può richiedere più tempo per batch grandi
job_timeout = 300
# Retry automatico in caso di errore
max_tries = 3
+34
View File
@@ -76,6 +76,10 @@ class Mailbox(Base):
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Protocollo (Feature N8 REM europea)
protocol_type: Mapped[str] = mapped_column(String(10), nullable=False, default="pec_it")
rem_provider: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
@@ -121,6 +125,10 @@ class Message(Base):
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Protocollo e REM europea (Feature N8)
protocol_type: Mapped[str] = mapped_column(String(10), nullable=False, default="pec_it")
rem_evidence_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -286,6 +294,32 @@ class NotificationRule(Base):
)
class TenantSettings(Base):
"""
Configurazione per-tenant archiviazione sostitutiva.
Replica del modello backend, letta dal worker per recuperare le credenziali
del conservatore (decifrate a runtime da decrypt_credential).
"""
__tablename__ = "tenant_settings"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
archival_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mock")
conservatore_id: Mapped[str] = mapped_column(String(100), nullable=False, default="mock")
conservatore_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
conservatore_username_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
conservatore_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
conservatore_tenant_slug: Mapped[str | None] = mapped_column(Text, nullable=True)
archival_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class NotificationLog(Base):
"""
Log di ogni tentativo di notifica con retry e circuit breaker.
+17 -1
View File
@@ -171,12 +171,28 @@ def parse_eml(raw_bytes: bytes, is_receipt: bool = False) -> ParsedEmail:
# ─── Helper privati ───────────────────────────────────────────────────────────
# Nomi file usati dall'infrastruttura PEC (non allegati utente)
# Nomi file usati dall'infrastruttura PEC italiana (non allegati utente)
_PEC_SYSTEM_FILENAMES = frozenset({
# ── PEC italiana (DM 2 novembre 2005) ─────────────────────────────────────
"daticert.xml",
"postacert.eml",
"smime.p7s",
"smime.p7m",
# ── REM europea (ETSI EN 319 532-4) ──────────────────────────────────────
"remevidence.xml",
"rem-evidence.xml",
"rem_evidence.xml",
"remreceipt.xml",
"rem-receipt.xml",
"remdispatch.xml",
"rem-dispatch.xml",
"remdelivery.xml",
"rem-delivery.xml",
"remdispatch.eml",
"rem-dispatch.eml",
"remsignature.p7s",
"rem-signature.p7s",
"signed-rem-dispatch.p7m",
})
# Content-type usati dall'infrastruttura PEC
+24
View File
@@ -170,6 +170,30 @@ def get_state_transition(pec_type: str) -> str | None:
return _RECEIPT_TO_STATE.get(pec_type)
def detect_protocol(msg: email.message.Message) -> str:
"""
Determina il protocollo di un messaggio in arrivo.
Logica di rilevamento automatico:
- Se il messaggio contiene almeno un header X-REM-*, il protocollo e' REM europea
- Altrimenti e' PEC italiana (default)
Questo permette al worker di usare il parser corretto (classify_rem_message vs
classify_pec_message) anche per caselle configurate come 'pec_it' che potrebbero
ricevere messaggi REM da partner europei (caso edge).
Args:
msg: oggetto email.message.Message gia' parsato dagli header
Returns:
'rem_eu' se header X-REM-* rilevati, 'pec_it' altrimenti.
"""
for header_name in msg.keys():
if header_name.upper().startswith("X-REM-"):
return "rem_eu"
return "pec_it"
def apply_outbound_transition(current_state: str, pec_type: str) -> str | None:
"""
Applica la state machine al messaggio outbound.
+202
View File
@@ -0,0 +1,202 @@
"""
Parser specifico per messaggi REM (Registered Electronic Mail europea).
Standard di riferimento: ETSI EN 319 532-4
"Electronic Registered Delivery Services REMDispatch and Evidence"
La REM europea e' il protocollo transfrontaliero equivalente alla PEC italiana.
Usa header X-REM-* invece degli header X-Ricevuta / X-TipoRicevuta della PEC italiana.
Header REM principali letti:
X-REM-Evidence-Type tipo evidenza (SubmissionAcceptance, DeliveryInformation, ecc.)
X-REM-Type alias usato da alcuni provider (stesso significato)
X-REM-Orig-Message-Id Message-ID del messaggio originale (correlazione)
X-REM-Message-Id alias per il Message-ID di riferimento
X-REM-Delivery-Status success/failure per DeliveryInformation
X-REM-Timestamp timestamp ISO 8601 (informativo)
X-REM-Provider nome provider REM (informativo)
Strategia di mapping:
I tipi evidenza REM vengono mappati agli stessi enum DB della PEC italiana
(pec_type enum: posta_certificata, accettazione, presa_in_carico, ecc.)
senza aggiungere nuovi tipi DB. Il valore originale viene conservato in
rem_evidence_type per trasparenza.
"""
import email.message
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# ─── Mapping evidence type REM → enum DB (riusa enum PEC italiana) ───────────
#
# ETSI EN 319 532-4 evidence types e loro equivalenti PEC italiani:
#
# REMDispatch → posta_certificata (il messaggio inviato)
# SubmissionAcceptance → accettazione (accettato dal provider mittente)
# RelayAcceptance → presa_in_carico (accettato per inoltro al provider destinatario)
# DeliveryInformation → avvenuta_consegna (consegnato, se X-REM-Delivery-Status = success)
# → errore_consegna (se X-REM-Delivery-Status = failed/error)
# DeliveryNonAcceptance → non_accettazione (consegna rifiutata dal destinatario)
# DeliveryExpiry → mancata_consegna (scadenza senza consegna)
# RetrievalNonAcceptance → mancata_consegna (non recuperato dal destinatario)
# ReceivedByNonREM → presa_in_carico (consegnato a sistema non-REM)
# SubmissionRejection → non_accettazione (rifiutato dal provider mittente)
# RelayRejection → non_accettazione (rifiutato durante relay)
_REM_TYPE_MAP: dict[str, str] = {
# ── Nomi standard ETSI (PascalCase) ──────────────────────────────────────
"remdispatch": "posta_certificata",
"submissionacceptance": "accettazione",
"relayacceptance": "presa_in_carico",
"deliveryinformation": "avvenuta_consegna", # rifinito con X-REM-Delivery-Status
"deliverynonacceptance": "non_accettazione",
"deliveryexpiry": "mancata_consegna",
"retrievalnonacceptance": "mancata_consegna",
"receivedbynonrem": "presa_in_carico",
"submissionrejection": "non_accettazione",
"relayrejection": "non_accettazione",
# ── Varianti con trattini (usate da alcuni provider europei) ─────────────
"rem-dispatch": "posta_certificata",
"submission-acceptance": "accettazione",
"relay-acceptance": "presa_in_carico",
"delivery-information": "avvenuta_consegna",
"delivery-non-acceptance": "non_accettazione",
"delivery-expiry": "mancata_consegna",
"retrieval-non-acceptance": "mancata_consegna",
"received-by-non-rem": "presa_in_carico",
"submission-rejection": "non_accettazione",
"relay-rejection": "non_accettazione",
# ── Varianti con underscore ───────────────────────────────────────────────
"rem_dispatch": "posta_certificata",
"submission_acceptance": "accettazione",
"relay_acceptance": "presa_in_carico",
"delivery_information": "avvenuta_consegna",
"delivery_non_acceptance": "non_accettazione",
"delivery_expiry": "mancata_consegna",
"retrieval_non_acceptance": "mancata_consegna",
"received_by_non_rem": "presa_in_carico",
"submission_rejection": "non_accettazione",
"relay_rejection": "non_accettazione",
}
# Valori X-REM-Delivery-Status che indicano fallimento della consegna
# (usati per distinguere DeliveryInformation success da failure)
_DELIVERY_FAILURE_STATUSES: frozenset[str] = frozenset({
"failed", "failure", "error", "rejected", "undeliverable",
"not-delivered", "not_delivered", "ko",
})
@dataclass
class RemClassification:
"""Risultato della classificazione di un messaggio REM."""
pec_type: str # valore enum DB (riusa enum PEC italiana)
rem_evidence_type: str | None # valore raw dell'header X-REM-Evidence-Type
riferimento_message_id: str | None # X-REM-Orig-Message-Id (correlazione outbound)
is_receipt: bool # True se e' un'evidenza (non il messaggio originale)
def classify_rem_message(msg: email.message.Message) -> RemClassification:
"""
Classifica il tipo di messaggio REM analizzando gli header X-REM-*.
Header letti (in ordine di priorita'):
1. X-REM-Evidence-Type (tipo evidenza principale, ETSI EN 319 532-4)
2. X-REM-Type (alias usato da alcuni provider)
3. X-REM-Orig-Message-Id (correlazione con il messaggio originale)
4. X-REM-Message-Id (alias per il Message-ID di riferimento)
5. X-REM-Delivery-Status (per DeliveryInformation: distingue success/failure)
Se X-REM-Evidence-Type non e' presente, il messaggio viene classificato
come 'posta_certificata' (REMDispatch: il messaggio originale).
Returns:
RemClassification con pec_type mappato all'enum DB esistente.
"""
# Header principali
raw_evidence = _clean(
msg.get("X-REM-Evidence-Type") or msg.get("X-REM-Type")
)
x_ref = _clean(
msg.get("X-REM-Orig-Message-Id") or msg.get("X-REM-Message-Id")
)
delivery_status = _clean(msg.get("X-REM-Delivery-Status"))
# Normalizza la chiave per il lookup nella mappa
evidence_key = (raw_evidence or "").lower().strip()
pec_type = _REM_TYPE_MAP.get(evidence_key, "posta_certificata")
# Gestione speciale DeliveryInformation:
# Se X-REM-Delivery-Status indica un fallimento, ri-mappa su errore_consegna
if pec_type == "avvenuta_consegna" and delivery_status:
if delivery_status.lower().strip() in _DELIVERY_FAILURE_STATUSES:
pec_type = "errore_consegna"
logger.debug(
f"REM DeliveryInformation con status={delivery_status!r}: "
f"rimappato a errore_consegna"
)
is_receipt = pec_type != "posta_certificata"
logger.debug(
f"REM classify: evidence={raw_evidence!r} → pec_type={pec_type!r} "
f"is_receipt={is_receipt} ref={x_ref!r}"
)
return RemClassification(
pec_type=pec_type,
rem_evidence_type=raw_evidence,
riferimento_message_id=x_ref,
is_receipt=is_receipt,
)
def detect_rem_headers(msg: email.message.Message) -> bool:
"""
Verifica se un messaggio contiene header REM (X-REM-*).
Usato da detect_protocol() in pec_parser.py per determinare automaticamente
se un messaggio e' REM europea o PEC italiana durante la sincronizzazione.
Returns:
True se almeno un header X-REM-* e' presente, False altrimenti.
"""
for header_name in msg.keys():
if header_name.upper().startswith("X-REM-"):
return True
return False
# ─── Verifica file di sistema REM ─────────────────────────────────────────────
# Nomi file usati dall'infrastruttura REM (non allegati utente)
# Analoghi a daticert.xml / postacert.eml della PEC italiana
REM_SYSTEM_FILENAMES: frozenset[str] = frozenset({
"remevidence.xml",
"rem-evidence.xml",
"rem_evidence.xml",
"remreceipt.xml",
"rem-receipt.xml",
"remdispatch.xml",
"rem-dispatch.xml",
"remdelivery.xml",
"rem-delivery.xml",
"remdispatch.eml",
"rem-dispatch.eml",
"remsignature.p7s",
"rem-signature.p7s",
"signed-rem-dispatch.p7m",
})
def _clean(value: str | None) -> str | None:
"""Pulisce e normalizza il valore di un header REM."""
if not value:
return None
return value.strip()
+38
View File
@@ -0,0 +1,38 @@
"""
Modulo sicurezza worker decifratura credenziali AES-256-GCM.
Replica solo le funzioni necessarie al worker (decrypt_credential).
La chiave di cifratura viene letta dalla variabile d'ambiente ENCRYPTION_KEY
tramite WorkerSettings (stesso valore del backend).
Formato storage: base64(nonce_12byte || ciphertext || tag_16byte)
"""
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.config import get_settings
settings = get_settings()
def decrypt_credential(encrypted: str) -> str:
"""
Decifra una stringa cifrata con AES-256-GCM.
Compatibile con encrypt_credential() del backend (stesso formato).
Solleva ValueError se la decifratura fallisce.
"""
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 credenziale fallita: {e}") from e
+516
View File
@@ -0,0 +1,516 @@
"""
Script di test trasmissione verso Aeterna (archiviazione certificata).
Questo script è STANDALONE: si connette direttamente al DB PecHub,
trova i messaggi con is_pending_conservation=True, scarica i loro EML
da MinIO, costruisce pacchetti SIP BagIt e li invia ad Aeterna.
Utilizzo (eseguire dal server dentro il container worker o direttamente):
# Sul server, dentro il container worker:
docker exec -it pechub-worker-1 python /app/scripts/test_aeterna_transmission.py
# Con credenziali personalizzate:
AETERNA_USERNAME=xxx AETERNA_PASSWORD=yyy \
python worker/scripts/test_aeterna_transmission.py
Variabili d'ambiente accettate (sovrascrivono i default):
AETERNA_ENDPOINT Default: https://api.aeterna.idrainformatica.it
AETERNA_USERNAME Default: matteo@idrainformatica.it
AETERNA_PASSWORD Default: letto da .env
AETERNA_TENANT_SLUG Default: pechub
DATABASE_URL Default: letta da .env del worker
Output:
Per ogni messaggio: stato trasmissione, versamento_id Aeterna, latenza.
Al termine: aggiorna is_conserved=True nel DB se l'ingest e' riuscito.
"""
from __future__ import annotations
import asyncio
import hashlib
import io
import json
import os
import sys
import time
import uuid
import zipfile
from datetime import UTC, datetime
from pathlib import Path
# ─── Setup path per importare dal worker ─────────────────────────────────────
# Se eseguito da fuori il container, aggiungi il path del worker
worker_dir = Path(__file__).parent.parent
sys.path.insert(0, str(worker_dir))
# ─── Configurazione Aeterna ───────────────────────────────────────────────────
AETERNA_ENDPOINT = os.getenv("AETERNA_ENDPOINT", "https://api.aeterna.idrainformatica.it")
AETERNA_USERNAME = os.getenv("AETERNA_USERNAME", "matteo@idrainformatica.it")
AETERNA_PASSWORD = os.getenv("AETERNA_PASSWORD", "Ma212718!")
AETERNA_TENANT_SLUG = os.getenv("AETERNA_TENANT_SLUG", "pechub")
# ─── Funzioni helper ──────────────────────────────────────────────────────────
def log(msg: str) -> None:
ts = datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] {msg}")
def log_section(title: str) -> None:
print(f"\n{'='*60}")
print(f" {title}")
print(f"{'='*60}")
def build_bagit_sip(
eml_bytes: bytes,
message_id: str,
subject: str | None = None,
from_address: str | None = None,
to_addresses: list[str] | None = None,
received_at: str | None = None,
) -> bytes:
"""Costruisce un pacchetto BagIt RFC 8493 in memoria (ZIP)."""
bag_name = f"pechub-pec-{message_id}"
eml_filename = f"{message_id}.eml"
data_path = f"data/{eml_filename}"
eml_sha256 = hashlib.sha256(eml_bytes).hexdigest()
bagit_txt = "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n"
bag_info_lines = [
"Bag-Software-Agent: PecHub Archival Module (test script)",
f"Bagging-Date: {datetime.now(UTC).strftime('%Y-%m-%d')}",
f"External-Identifier: {message_id}",
"Source-Organization: PecHub",
]
if subject:
bag_info_lines.append(f"Description: {subject[:500]}")
if from_address:
bag_info_lines.append(f"Contact-Email: {from_address}")
if to_addresses:
bag_info_lines.append(f"External-Description: PEC a {', '.join(to_addresses[:3])}")
if received_at:
bag_info_lines.append(f"Bag-Group-Identifier: {received_at[:10]}")
bag_info_txt = "\n".join(bag_info_lines) + "\n"
manifest_txt = f"{eml_sha256} {data_path}\n"
buf = io.BytesIO()
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"{bag_name}/bagit.txt", bagit_txt)
zf.writestr(f"{bag_name}/bag-info.txt", bag_info_txt)
zf.writestr(f"{bag_name}/manifest-sha256.txt", manifest_txt)
zf.writestr(f"{bag_name}/{data_path}", eml_bytes)
return buf.getvalue()
# ─── Client Aeterna (inline, senza dipendenze worker) ────────────────────────
class AeternaTestClient:
"""Client minimale per il test di trasmissione."""
def __init__(self, endpoint: str, username: str, password: str, tenant_slug: str):
self.endpoint = endpoint.rstrip("/")
self.username = username
self.password = password
self.tenant_slug = tenant_slug
self._token: str | None = None
self._token_expires_at: float = 0.0
async def login(self) -> str:
import httpx
log(f" Autenticazione su Aeterna ({self.endpoint}) ...")
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{self.endpoint}/api/v1/auth/login",
json={
"email": self.username,
"password": self.password,
"tenant_slug": self.tenant_slug,
},
)
if resp.status_code != 200:
raise RuntimeError(
f"Login fallito HTTP {resp.status_code}: {resp.text[:300]}"
)
data = resp.json()
self._token = data["access_token"]
expires_in = int(data.get("expires_in", 3600))
self._token_expires_at = time.monotonic() + expires_in - 60
user_email = data.get("user", {}).get("email", "?")
log(f" Login riuscito come: {user_email}")
return self._token
async def get_token(self) -> str:
if self._token and time.monotonic() < self._token_expires_at:
return self._token
return await self.login()
async def upload_sip(
self,
zip_bytes: bytes,
zip_filename: str,
title: str,
description: str = "",
) -> dict:
import httpx
token = await self.get_token()
log(f" Upload SIP '{zip_filename}' ({len(zip_bytes):,} bytes) ...")
t_start = time.monotonic()
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
f"{self.endpoint}/api/v1/ingest/upload",
headers={"Authorization": f"Bearer {token}"},
files={"file": (zip_filename, zip_bytes, "application/zip")},
data={
"title": title[:500],
"description": description[:500],
"creator": "PecHub Test Script",
},
)
latency_ms = int((time.monotonic() - t_start) * 1000)
if resp.status_code in (200, 201, 202):
data = resp.json()
log(f" Upload OK in {latency_ms}ms package_id: {data.get('package_id')}")
return {"success": True, "latency_ms": latency_ms, **data}
else:
log(f" Upload FALLITO HTTP {resp.status_code}: {resp.text[:300]}")
return {
"success": False,
"latency_ms": latency_ms,
"error": resp.text[:300],
"status_code": resp.status_code,
}
async def poll_status(self, package_id: str, max_polls: int = 10, interval: float = 3.0) -> dict:
import httpx
token = await self.get_token()
log(f" Polling status package_id={package_id} (max {max_polls} tentativi) ...")
for i in range(max_polls):
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self.endpoint}/api/v1/ingest/{package_id}/status",
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code == 200:
data = resp.json()
status = data.get("status", "UNKNOWN").upper()
stage = data.get("pipeline_stage", "")
pct = data.get("progress_pct", 0)
log(f" [{i+1}/{max_polls}] status={status} stage={stage} progress={pct}%")
if status in ("ACTIVE", "FAILED", "REJECTED"):
return {"success": status == "ACTIVE", "final_status": status, **data}
if i < max_polls - 1:
await asyncio.sleep(interval)
else:
log(f" Polling error HTTP {resp.status_code}")
break
log(" Polling completato (stato non finale raggiunto, processo ancora in corso)")
return {"success": None, "message": "polling esaurito"}
# ─── Recupero messaggi da DB e MinIO ─────────────────────────────────────────
async def get_pending_conservation_messages() -> list[dict]:
"""
Recupera i messaggi con is_pending_conservation=True dal DB PecHub.
Restituisce una lista di dict con i campi rilevanti.
"""
try:
import asyncpg # type: ignore[import]
except ImportError:
log("asyncpg non installato. Uso psycopg2 come fallback...")
return await get_messages_via_env()
db_url = os.getenv("DATABASE_URL", "")
if not db_url:
log("DATABASE_URL non impostata. Tento connessione locale...")
db_url = "postgresql://pechub:pechub@localhost:5432/pechub"
# asyncpg vuole postgresql:// non postgres:// e senza +asyncpg driver specifier
db_url = db_url.replace("postgres://", "postgresql://")
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
db_url = db_url.replace("postgresql+psycopg2://", "postgresql://")
try:
conn = await asyncpg.connect(db_url)
rows = await conn.fetch("""
SELECT
m.id,
m.tenant_id,
m.subject,
m.from_address,
m.to_addresses,
m.received_at,
m.raw_eml_path,
m.is_pending_conservation,
m.is_conserved
FROM messages m
WHERE m.is_pending_conservation = TRUE
AND m.is_conserved = FALSE
ORDER BY m.received_at DESC
LIMIT 20
""")
await conn.close()
return [dict(row) for row in rows]
except Exception as e:
log(f"Errore connessione DB: {e}")
return []
async def get_messages_via_env() -> list[dict]:
"""Fallback: usa variabili d'ambiente per costruire messaggi di test."""
log("Uso messaggi di test hardcoded (DB non disponibile)")
return []
async def download_eml_from_minio(raw_eml_path: str) -> bytes | None:
"""
Scarica il file EML da MinIO usando il path memorizzato nel DB.
"""
try:
from app.storage.minio_client import get_minio_client
client = await get_minio_client()
bucket = os.getenv("MINIO_BUCKET", "pechub")
response = await asyncio.to_thread(
client.get_object, bucket, raw_eml_path
)
data = response.read()
response.close()
response.release_conn()
return data
except ImportError:
pass
except Exception as e:
log(f" Errore download MinIO ({raw_eml_path}): {e}")
# Fallback: prova con boto3/minio direttamente
try:
import minio # type: ignore[import]
endpoint = os.getenv("MINIO_ENDPOINT", "localhost:9000")
access_key = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
secret_key = os.getenv("MINIO_SECRET_KEY", "minioadmin")
bucket = os.getenv("MINIO_BUCKET", "pechub")
client = minio.Minio(
endpoint,
access_key=access_key,
secret_key=secret_key,
secure=endpoint.startswith("https"),
)
response = client.get_object(bucket, raw_eml_path)
data = response.read()
response.close()
response.release_conn()
return data
except Exception as e:
log(f" Errore download MinIO (fallback): {e}")
return None
async def mark_message_conserved(message_id: str, versamento_id: str) -> None:
"""Aggiorna il messaggio nel DB come conservato."""
try:
import asyncpg # type: ignore[import]
except ImportError:
log(f" [skip] asyncpg non disponibile: impossibile aggiornare is_conserved per {message_id}")
return
db_url = os.getenv("DATABASE_URL", "postgresql://pechub:pechub@localhost:5432/pechub")
db_url = db_url.replace("postgres://", "postgresql://")
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
db_url = db_url.replace("postgresql+psycopg2://", "postgresql://")
try:
conn = await asyncpg.connect(db_url)
await conn.execute("""
UPDATE messages
SET is_conserved = TRUE,
conserved_at = NOW()
WHERE id = $1
""", uuid.UUID(message_id))
await conn.close()
log(f" DB aggiornato: is_conserved=TRUE per message_id={message_id}")
except Exception as e:
log(f" Errore aggiornamento DB per {message_id}: {e}")
# ─── Main ─────────────────────────────────────────────────────────────────────
async def main() -> None:
log_section("TEST TRASMISSIONE AETERNA PecHub")
log(f"Endpoint: {AETERNA_ENDPOINT}")
log(f"Username: {AETERNA_USERNAME}")
log(f"Tenant slug: {AETERNA_TENANT_SLUG}")
log(f"Timestamp: {datetime.now().isoformat()}")
# 1. Connessione ad Aeterna
log_section("1. AUTENTICAZIONE AETERNA")
client = AeternaTestClient(
endpoint=AETERNA_ENDPOINT,
username=AETERNA_USERNAME,
password=AETERNA_PASSWORD,
tenant_slug=AETERNA_TENANT_SLUG,
)
try:
await client.login()
except Exception as e:
log(f"ERRORE FATALE: impossibile autenticarsi su Aeterna: {e}")
sys.exit(1)
# 2. Recupera messaggi da conservare
log_section("2. RECUPERO MESSAGGI 'DA CONSERVARE'")
messages = await get_pending_conservation_messages()
if not messages:
log("Nessun messaggio con is_pending_conservation=TRUE trovato.")
log("Verificare che i messaggi siano stati marcati 'Da conservare' nell'interfaccia.")
log("")
log("Suggerimento: selezionare un messaggio in PecHub e usare")
log("'Aggiungi a Da Conservare' per marcarlo per l'archiviazione.")
sys.exit(0)
log(f"Trovati {len(messages)} messaggi da conservare:")
for i, m in enumerate(messages, 1):
subj = (m.get("subject") or "")[:60]
recv = str(m.get("received_at") or "")[:10]
log(f" [{i}] id={str(m['id'])[:8]}... | data={recv} | oggetto={subj}")
# 3. Trasmissione
log_section("3. TRASMISSIONE A AETERNA")
results = []
for i, msg in enumerate(messages, 1):
msg_id = str(msg["id"])
subject = msg.get("subject") or f"PEC {msg_id[:8]}"
from_addr = msg.get("from_address")
to_addrs = msg.get("to_addresses") or []
received_at = str(msg.get("received_at") or "")
raw_eml_path = msg.get("raw_eml_path")
log(f"\nMessaggio [{i}/{len(messages)}]: {subject[:50]}")
log(f" ID: {msg_id}")
log(f" EML path: {raw_eml_path}")
# Scarica EML
eml_bytes: bytes | None = None
if raw_eml_path:
log(" Download EML da MinIO ...")
eml_bytes = await download_eml_from_minio(raw_eml_path)
if not eml_bytes:
# Crea un EML di test sintetico
log(" EML non disponibile. Generazione EML sintetico di test ...")
eml_bytes = f"""From: {from_addr or 'test@pec.it'}
To: {', '.join(to_addrs) if to_addrs else 'destinatario@pec.it'}
Subject: {subject}
Date: {received_at}
Message-ID: <{msg_id}@pechub.test>
Content-Type: text/plain; charset=UTF-8
MIME-Version: 1.0
Questo e' un messaggio PEC archiviato da PecHub.
ID messaggio: {msg_id}
Data archiviazione: {datetime.now(UTC).isoformat()}
""".encode("utf-8")
# Costruisci BagIt SIP
log(" Costruzione pacchetto BagIt SIP ...")
zip_bytes = build_bagit_sip(
eml_bytes=eml_bytes,
message_id=msg_id,
subject=subject,
from_address=from_addr,
to_addresses=to_addrs,
received_at=received_at,
)
log(f" SIP costruito: {len(zip_bytes):,} bytes")
# Upload su Aeterna
upload_result = await client.upload_sip(
zip_bytes=zip_bytes,
zip_filename=f"pechub-pec-{msg_id}.zip",
title=subject,
description=f"Messaggio PEC ID={msg_id} | Da={from_addr} | A={', '.join(to_addrs or [])}",
)
if not upload_result.get("success"):
log(f" UPLOAD FALLITO: {upload_result.get('error', 'errore sconosciuto')}")
results.append({
"message_id": msg_id,
"subject": subject,
"success": False,
"error": upload_result.get("error"),
})
continue
package_id = upload_result.get("package_id")
pid = upload_result.get("pid", "")
log(f" package_id = {package_id}")
log(f" pid = {pid}")
# Polling status (opzionale, non bloccante per il test)
log(" Attesa elaborazione pipeline (polling 5 poll x 4s) ...")
status_result = await client.poll_status(package_id, max_polls=5, interval=4.0)
final_status = status_result.get("final_status", "unknown")
log(f" Stato finale Aeterna: {final_status}")
# Aggiorna DB se accettato
if final_status == "ACTIVE" or status_result.get("success") is None:
# success=None significa che l'ingest e' ancora in corso ma accettato
await mark_message_conserved(msg_id, package_id)
results.append({
"message_id": msg_id,
"subject": subject,
"success": True,
"package_id": package_id,
"pid": pid,
"final_status": final_status,
"latency_ms": upload_result.get("latency_ms"),
})
# 4. Riepilogo
log_section("4. RIEPILOGO")
ok = sum(1 for r in results if r.get("success"))
log(f"Messaggi trasmessi con successo: {ok}/{len(results)}")
log("")
for r in results:
icon = "OK" if r.get("success") else "FAIL"
log(f" [{icon}] {r['subject'][:50]}")
if r.get("package_id"):
log(f" package_id = {r['package_id']}")
log(f" pid = {r.get('pid', '-')}")
log(f" status = {r.get('final_status', '-')}")
log(f" latency = {r.get('latency_ms', '-')} ms")
if r.get("error"):
log(f" error = {r['error'][:100]}")
# Salva risultati in JSON
output_file = Path("/tmp/aeterna_test_results.json")
output_file.write_text(json.dumps(results, indent=2, default=str))
log(f"\nRisultati salvati in: {output_file}")
if ok == len(results) and results:
log("\nTest completato con successo!")
elif not results:
log("\nNessun messaggio trasmesso.")
else:
log(f"\nTest parzialmente riuscito ({ok}/{len(results)} messaggi trasmessi).")
if __name__ == "__main__":
asyncio.run(main())