GapFill Flowee
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user