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
+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)
+49 -13
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)
pec_class = classify_pec_message(quick_msg)
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