Files
2026-04-07 11:32:03 +02:00

198 lines
8.2 KiB
Python

"""
Parser specifico per header PEC (Fase 3).
Classifica il tipo di messaggio PEC leggendo gli header:
- X-Ricevuta → tipo ricevuta (Aruba, Legalmail)
- X-TipoRicevuta → tipo ricevuta alternativo (Namirial)
- X-Riferimento-Message-ID → Message-ID del messaggio originale
- X-Trasporto → indicatore trasporto PEC (presenza indica PEC)
Il risultato della classificazione determina:
- `pec_type`: valore enum DB (posta_certificata, accettazione, ...)
- `is_receipt`: True se il messaggio è una ricevuta (non l'originale)
- `riferimento_message_id`: usato per collegare parent_message_id
State machine messaggi outbound:
sent/queued → accepted (ricevuta di accettazione o presa in carico)
sent/queued → delivered (ricevuta di consegna arrivata prima dell'accettazione)
accepted → delivered (ricevuta di avvenuta consegna)
any valid → anomaly (non-accettazione, mancata consegna, errore, virus)
"""
import email.message
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# ─── Mapping header PEC → enum DB ────────────────────────────────────────────
#
# I diversi provider PEC usano varianti nei valori degli header:
# Aruba PEC: X-Ricevuta con trattini (es. "avvenuta-consegna")
# Namirial: X-TipoRicevuta (stessa sintassi di Aruba)
# Legalmail: X-Ricevuta, talvolta con underscore o spazi
# InfoCert: X-Ricevuta, stessa sintassi Aruba
_PEC_TYPE_MAP: dict[str, str] = {
# ── Standard con trattini (Aruba, Namirial, InfoCert) ──────────────────
"accettazione": "accettazione",
"non-accettazione": "non_accettazione",
"presa-in-carico": "presa_in_carico",
"avvenuta-consegna": "avvenuta_consegna",
"mancata-consegna": "mancata_consegna",
"errore-consegna": "errore_consegna",
"preavviso-mancata-consegna": "preavviso_mancata_consegna",
"rilevazione-virus": "rilevazione_virus",
"posta-certificata": "posta_certificata",
# ── Con underscore (variante Legalmail/Poste) ───────────────────────────
"non_accettazione": "non_accettazione",
"presa_in_carico": "presa_in_carico",
"avvenuta_consegna": "avvenuta_consegna",
"mancata_consegna": "mancata_consegna",
"errore_consegna": "errore_consegna",
"preavviso_mancata_consegna": "preavviso_mancata_consegna",
"rilevazione_virus": "rilevazione_virus",
"posta_certificata": "posta_certificata",
# ── Con spazio (variante Legalmail rara) ────────────────────────────────
"non accettazione": "non_accettazione",
"presa in carico": "presa_in_carico",
"avvenuta consegna": "avvenuta_consegna",
"mancata consegna": "mancata_consegna",
"errore consegna": "errore_consegna",
"preavviso mancata consegna": "preavviso_mancata_consegna",
"rilevazione virus": "rilevazione_virus",
# ── Abbreviazioni usate da alcuni provider ──────────────────────────────
"consegna": "avvenuta_consegna", # Legalmail abbreviazione
"no-consegna": "mancata_consegna",
}
@dataclass
class PecClassification:
"""Risultato della classificazione di un messaggio PEC."""
pec_type: str # valore enum DB
riferimento_message_id: str | None # X-Riferimento-Message-ID
x_ricevuta: str | None # valore raw dell'header X-Ricevuta
x_tipo_ricevuta: str | None # valore raw dell'header X-TipoRicevuta
is_receipt: bool # True se è ricevuta (non messaggio originale)
def classify_pec_message(msg: email.message.Message) -> PecClassification:
"""
Classifica il tipo di messaggio PEC analizzando gli header specifici.
Header letti (in ordine di priorità):
1. X-TipoRicevuta (Namirial, standard più recente)
2. X-Ricevuta (Aruba, Legalmail, InfoCert)
Se nessuno dei due è presente, il messaggio è classificato come
'posta_certificata' (messaggio PEC originale).
Per la correlazione con il messaggio originale (outbound):
- X-Riferimento-Message-ID contiene il Message-ID del messaggio inviato
"""
x_ricevuta = _clean_header(msg.get("X-Ricevuta"))
x_tipo = _clean_header(msg.get("X-TipoRicevuta"))
x_riferimento = _clean_header(msg.get("X-Riferimento-Message-ID"))
# X-TipoRicevuta ha precedenza
raw_type = (x_tipo or x_ricevuta or "").lower()
pec_type = _PEC_TYPE_MAP.get(raw_type, "posta_certificata")
is_receipt = pec_type != "posta_certificata"
return PecClassification(
pec_type=pec_type,
riferimento_message_id=x_riferimento,
x_ricevuta=x_ricevuta,
x_tipo_ricevuta=x_tipo,
is_receipt=is_receipt,
)
def _clean_header(value: str | None) -> str | None:
"""Pulisce e normalizza il valore di un header PEC."""
if not value:
return None
return value.strip()
# ─── State machine messaggi outbound ─────────────────────────────────────────
#
# Quando il worker riceve una ricevuta PEC (inbound), deve aggiornare
# lo stato del messaggio outbound corrispondente (trovato tramite
# X-Riferimento-Message-ID → message_id_header).
#
# Transizioni valide:
# (current_state, receipt_type) → new_state
#
# Stati terminali: "delivered", "anomaly", "failed"
# Non si deve retrocedere di stato (es. delivered → anomaly non è valido)
# Tipo ricevuta → nuovo stato da applicare al messaggio outbound
_RECEIPT_TO_STATE: dict[str, str] = {
"accettazione": "accepted",
"presa_in_carico": "accepted", # equivalente funzionale ad accepted
"avvenuta_consegna": "delivered",
"non_accettazione": "anomaly",
"mancata_consegna": "anomaly",
"errore_consegna": "anomaly",
"preavviso_mancata_consegna": "anomaly",
"rilevazione_virus": "anomaly",
}
# Transizioni di stato valide per ciascuno stato corrente
# Solo le transizioni in avanti sono permesse (no retrocessioni)
# NOTA: "delivered" e' ammesso anche da "sent"/"queued" perche' i gestori PEC
# possono inviare la ricevuta di consegna prima di quella di accettazione
# (o in assenza della ricevuta di accettazione). Se arriva prima la consegna
# lo stato deve diventare "delivered" indipendentemente dall'ordine di arrivo.
VALID_OUTBOUND_TRANSITIONS: dict[str, set[str]] = {
"queued": {"accepted", "delivered", "anomaly"},
"sent": {"accepted", "delivered", "anomaly"},
"accepted": {"delivered", "anomaly"},
# delivered e anomaly sono terminali: nessuna transizione
}
def get_state_transition(pec_type: str) -> str | None:
"""
Restituisce il nuovo stato da applicare al messaggio outbound
quando arriva una ricevuta di questo tipo.
Ritorna None se il tipo non provoca transizioni di stato
(es. 'posta_certificata' non è una ricevuta).
"""
return _RECEIPT_TO_STATE.get(pec_type)
def apply_outbound_transition(current_state: str, pec_type: str) -> str | None:
"""
Applica la state machine al messaggio outbound.
Args:
current_state: stato corrente del messaggio outbound
pec_type: tipo della ricevuta arrivata
Returns:
Il nuovo stato se la transizione è valida, None altrimenti.
Ritorna None anche se lo stato è già terminale.
"""
new_state = get_state_transition(pec_type)
if new_state is None:
return None # Non è una ricevuta con effetto sullo stato
valid_targets = VALID_OUTBOUND_TRANSITIONS.get(current_state, set())
if new_state in valid_targets:
return new_state
logger.debug(
f"Transizione stato ignorata: {current_state!r}{new_state!r} "
f"(ricevuta: {pec_type!r})"
)
return None