mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
fase 3
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Parsers PEC – Fase 3.
|
||||
|
||||
Esporta i moduli principali per il parsing di messaggi PEC:
|
||||
- eml_parser: parsing MIME completo (body, allegati, header)
|
||||
- pec_parser: classificazione tipo PEC da header X-Ricevuta / X-TipoRicevuta
|
||||
- receipt_extractor: estrazione EML annidato nelle ricevute (EML-in-EML)
|
||||
"""
|
||||
|
||||
from app.parsers.eml_parser import AttachmentInfo, ParsedEmail, parse_eml
|
||||
from app.parsers.pec_parser import (
|
||||
PecClassification,
|
||||
VALID_OUTBOUND_TRANSITIONS,
|
||||
classify_pec_message,
|
||||
get_state_transition,
|
||||
)
|
||||
from app.parsers.receipt_extractor import (
|
||||
NestedEmlInfo,
|
||||
extract_nested_eml,
|
||||
extract_receipt_xml,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AttachmentInfo",
|
||||
"ParsedEmail",
|
||||
"parse_eml",
|
||||
"PecClassification",
|
||||
"VALID_OUTBOUND_TRANSITIONS",
|
||||
"classify_pec_message",
|
||||
"get_state_transition",
|
||||
"NestedEmlInfo",
|
||||
"extract_nested_eml",
|
||||
"extract_receipt_xml",
|
||||
]
|
||||
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
Parser MIME completo per messaggi email/PEC (Fase 3).
|
||||
|
||||
Responsabilità:
|
||||
- Parsing MIME ricorsivo (multipart, nested)
|
||||
- Estrazione body text e html
|
||||
- Estrazione allegati con filename, content_type, sha256
|
||||
- Decodifica header RFC 2047 (=?utf-8?b?...?=)
|
||||
- Gestione parti message/rfc822 (EML-in-EML per ricevute PEC)
|
||||
"""
|
||||
|
||||
import email
|
||||
import email.header
|
||||
import email.message
|
||||
import email.utils
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── Data classes ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttachmentInfo:
|
||||
"""Dati estratti da un singolo allegato MIME."""
|
||||
|
||||
filename: str
|
||||
content_type: str
|
||||
content: bytes
|
||||
size_bytes: int
|
||||
checksum_sha256: str
|
||||
is_inline: bool = False # True per parti inline (immagini nel corpo HTML)
|
||||
is_pec_system: bool = False # True per file di infrastruttura PEC (daticert.xml, postacert.eml)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedEmail:
|
||||
"""Risultato completo del parsing di un EML."""
|
||||
|
||||
subject: str | None = None
|
||||
from_address: str | None = None
|
||||
to_addresses: list[str] = field(default_factory=list)
|
||||
cc_addresses: list[str] = field(default_factory=list)
|
||||
message_id: str | None = None
|
||||
date: datetime | None = None
|
||||
body_text: str | None = None
|
||||
body_html: str | None = None
|
||||
attachments: list[AttachmentInfo] = field(default_factory=list)
|
||||
has_attachments: bool = False
|
||||
|
||||
# Riferimento all'oggetto email.message.Message originale (per parser PEC)
|
||||
raw_message: email.message.Message | None = None
|
||||
|
||||
|
||||
# ─── Funzioni helper (pubbliche, usate anche dai test) ───────────────────────
|
||||
|
||||
|
||||
def decode_header(value: str | None) -> str | None:
|
||||
"""
|
||||
Decodifica un header RFC 2047 (=?charset?encoding?text?=) in stringa Python.
|
||||
|
||||
Esempi:
|
||||
=?utf-8?b?UEVDIHRlc3Q=?= → "PEC test"
|
||||
=?iso-8859-1?q?Multa_n=2E_123?= → "Multa n. 123"
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parts = email.header.decode_header(value)
|
||||
decoded = []
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
decoded.append(str(part))
|
||||
return "".join(decoded).strip()
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def extract_addresses(field_value: str | None) -> list[str]:
|
||||
"""
|
||||
Estrae una lista di indirizzi email da un campo header To/Cc/From.
|
||||
|
||||
Gestisce sia "addr@example.com" sia "Nome <addr@example.com>".
|
||||
"""
|
||||
if not field_value:
|
||||
return []
|
||||
try:
|
||||
parsed = email.utils.getaddresses([field_value])
|
||||
return [addr for _, addr in parsed if addr]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def parse_date(date_str: str | None) -> datetime | None:
|
||||
"""
|
||||
Converte una stringa data RFC 2822 (header Date) in datetime con timezone.
|
||||
|
||||
Ritorna None in caso di stringa non valida.
|
||||
"""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
parsed = email.utils.parsedate_to_datetime(date_str)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ─── Parser principale ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_eml(raw_bytes: bytes) -> ParsedEmail:
|
||||
"""
|
||||
Parsing completo di un raw EML.
|
||||
|
||||
Estrae:
|
||||
- Header: Subject, From, To, Cc, Message-ID, Date
|
||||
- Body: text/plain, text/html (primo trovato per tipo)
|
||||
- Allegati: tutti i parti con filename, inclusi message/rfc822
|
||||
|
||||
Args:
|
||||
raw_bytes: byte del messaggio EML grezzo
|
||||
|
||||
Returns:
|
||||
ParsedEmail con tutti i campi estratti (fields None/[] se non presenti)
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return ParsedEmail()
|
||||
|
||||
try:
|
||||
msg = email.message_from_bytes(raw_bytes)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Errore parsing EML bytes: {exc}")
|
||||
return ParsedEmail()
|
||||
|
||||
result = ParsedEmail()
|
||||
result.raw_message = msg
|
||||
|
||||
# ── Header ────────────────────────────────────────────────────────────────
|
||||
result.subject = decode_header(msg.get("Subject"))
|
||||
result.from_address = email.utils.parseaddr(msg.get("From", ""))[1] or None
|
||||
result.to_addresses = extract_addresses(msg.get("To"))
|
||||
result.cc_addresses = extract_addresses(msg.get("Cc"))
|
||||
result.message_id = msg.get("Message-ID", "").strip() or None
|
||||
result.date = parse_date(msg.get("Date"))
|
||||
|
||||
# ── Body e allegati ───────────────────────────────────────────────────────
|
||||
if msg.is_multipart():
|
||||
_walk_parts(msg, result)
|
||||
else:
|
||||
_extract_single_part_body(msg, result)
|
||||
|
||||
result.has_attachments = any(
|
||||
not att.is_pec_system for att in result.attachments
|
||||
) if result.attachments else False
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ─── Helper privati ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Nomi file usati dall'infrastruttura PEC (non allegati utente)
|
||||
_PEC_SYSTEM_FILENAMES = frozenset({
|
||||
"daticert.xml",
|
||||
"postacert.eml",
|
||||
"smime.p7s",
|
||||
"smime.p7m",
|
||||
})
|
||||
|
||||
# Content-type usati dall'infrastruttura PEC
|
||||
_PEC_SYSTEM_CONTENT_TYPES = frozenset({
|
||||
"application/pkcs7-signature",
|
||||
"application/pkcs7-mime",
|
||||
})
|
||||
|
||||
|
||||
def _is_pec_system_part(filename: str | None, content_type: str) -> bool:
|
||||
"""Determina se un allegato è parte dell'infrastruttura PEC."""
|
||||
if filename and filename.lower() in _PEC_SYSTEM_FILENAMES:
|
||||
return True
|
||||
if content_type in _PEC_SYSTEM_CONTENT_TYPES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_filename(part: email.message.Message) -> str | None:
|
||||
"""
|
||||
Estrae il filename da un part MIME.
|
||||
|
||||
Prova nell'ordine:
|
||||
1. Content-Disposition: attachment; filename="..."
|
||||
2. Content-Type: ...; name="..."
|
||||
"""
|
||||
filename = part.get_filename()
|
||||
if filename:
|
||||
return decode_header(filename)
|
||||
name = part.get_param("name")
|
||||
if name:
|
||||
return decode_header(name)
|
||||
return None
|
||||
|
||||
|
||||
def _walk_parts(msg: email.message.Message, result: ParsedEmail) -> None:
|
||||
"""
|
||||
Naviga ricorsivamente tutti i part MIME del messaggio.
|
||||
|
||||
Logica:
|
||||
- multipart/* → salta (è un container, i figli vengono visitati nel loop)
|
||||
- text/plain → body_text (primo trovato, se non è "attachment")
|
||||
- text/html → body_html (primo trovato, se non è "attachment")
|
||||
- message/rfc822 → allegato di tipo EML-in-EML
|
||||
- tutto il resto con filename → allegato
|
||||
"""
|
||||
for part in msg.walk():
|
||||
# Salta container multipart
|
||||
if part.get_content_maintype() == "multipart":
|
||||
continue
|
||||
|
||||
ct = part.get_content_type()
|
||||
disp = part.get("Content-Disposition", "")
|
||||
filename = _get_filename(part)
|
||||
|
||||
# ── EML-in-EML (message/rfc822) ───────────────────────────────────────
|
||||
if ct == "message/rfc822":
|
||||
_extract_eml_in_eml(part, filename, result)
|
||||
continue
|
||||
|
||||
# ── Allegato esplicito (Content-Disposition: attachment) ──────────────
|
||||
if "attachment" in disp:
|
||||
if filename:
|
||||
_extract_attachment(part, filename, ct, is_inline=False, result=result)
|
||||
continue
|
||||
|
||||
# ── Parte inline con filename (es. immagine embeds in HTML) ───────────
|
||||
if "inline" in disp and filename:
|
||||
_extract_attachment(part, filename, ct, is_inline=True, result=result)
|
||||
continue
|
||||
|
||||
# ── Body text/plain ───────────────────────────────────────────────────
|
||||
if ct == "text/plain" and result.body_text is None:
|
||||
body = _decode_payload(part)
|
||||
if body is not None:
|
||||
result.body_text = body
|
||||
continue
|
||||
|
||||
# ── Body text/html ────────────────────────────────────────────────────
|
||||
if ct == "text/html" and result.body_html is None:
|
||||
body = _decode_payload(part)
|
||||
if body is not None:
|
||||
result.body_html = body
|
||||
continue
|
||||
|
||||
# ── Altri tipi con filename → allegato ────────────────────────────────
|
||||
if filename:
|
||||
_extract_attachment(part, filename, ct, is_inline=False, result=result)
|
||||
|
||||
|
||||
def _extract_single_part_body(msg: email.message.Message, result: ParsedEmail) -> None:
|
||||
"""Estrae il body per messaggi non-multipart."""
|
||||
ct = msg.get_content_type()
|
||||
body = _decode_payload(msg)
|
||||
if body is None:
|
||||
return
|
||||
if ct == "text/plain":
|
||||
result.body_text = body
|
||||
elif ct == "text/html":
|
||||
result.body_html = body
|
||||
|
||||
|
||||
def _decode_payload(part: email.message.Message) -> str | None:
|
||||
"""Decodifica il payload di una parte MIME come stringa testo."""
|
||||
try:
|
||||
raw = part.get_payload(decode=True)
|
||||
if raw is None:
|
||||
return None
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
return raw.decode(charset, errors="replace")
|
||||
except Exception as exc:
|
||||
logger.debug(f"Errore decodifica payload {part.get_content_type()}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _extract_eml_in_eml(
|
||||
part: email.message.Message,
|
||||
filename: str | None,
|
||||
result: ParsedEmail,
|
||||
) -> None:
|
||||
"""
|
||||
Estrae il messaggio EML annidato in un part message/rfc822.
|
||||
|
||||
Nella struttura PEC, questo è tipicamente il messaggio originale
|
||||
allegato alle ricevute di consegna/accettazione.
|
||||
"""
|
||||
try:
|
||||
payload = part.get_payload()
|
||||
|
||||
inner_bytes: bytes | None = None
|
||||
|
||||
if isinstance(payload, list) and payload:
|
||||
# Forma canonica: payload è lista di Message
|
||||
inner_msg = payload[0]
|
||||
if isinstance(inner_msg, email.message.Message):
|
||||
inner_bytes = inner_msg.as_bytes()
|
||||
|
||||
elif isinstance(payload, bytes):
|
||||
inner_bytes = payload
|
||||
|
||||
elif isinstance(payload, str):
|
||||
inner_bytes = payload.encode("utf-8", errors="replace")
|
||||
|
||||
if inner_bytes:
|
||||
eff_filename = filename or "inner_message.eml"
|
||||
is_system = _is_pec_system_part(eff_filename, "message/rfc822")
|
||||
att = AttachmentInfo(
|
||||
filename=eff_filename,
|
||||
content_type="message/rfc822",
|
||||
content=inner_bytes,
|
||||
size_bytes=len(inner_bytes),
|
||||
checksum_sha256=hashlib.sha256(inner_bytes).hexdigest(),
|
||||
is_inline=False,
|
||||
is_pec_system=is_system,
|
||||
)
|
||||
result.attachments.append(att)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(f"Errore estrazione EML-in-EML: {exc}")
|
||||
|
||||
|
||||
def _extract_attachment(
|
||||
part: email.message.Message,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
is_inline: bool,
|
||||
result: ParsedEmail,
|
||||
) -> None:
|
||||
"""Estrae il contenuto di un allegato e lo aggiunge al ParsedEmail."""
|
||||
try:
|
||||
content = part.get_payload(decode=True)
|
||||
if content is None:
|
||||
return
|
||||
is_system = _is_pec_system_part(filename, content_type)
|
||||
att = AttachmentInfo(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
content=content,
|
||||
size_bytes=len(content),
|
||||
checksum_sha256=hashlib.sha256(content).hexdigest(),
|
||||
is_inline=is_inline,
|
||||
is_pec_system=is_system,
|
||||
)
|
||||
result.attachments.append(att)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Errore estrazione allegato {filename!r}: {exc}")
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
VALID_OUTBOUND_TRANSITIONS: dict[str, set[str]] = {
|
||||
"queued": {"accepted", "anomaly"},
|
||||
"sent": {"accepted", "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
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Estrazione EML annidato nelle ricevute PEC (EML-in-EML) – Fase 3.
|
||||
|
||||
Una ricevuta PEC è strutturalmente un messaggio multipart che contiene:
|
||||
- text/plain → testo leggibile della ricevuta
|
||||
- application/xml → dati strutturati XML (daticert.xml, opzionale)
|
||||
- message/rfc822 → il messaggio originale allegato (o la busta PEC)
|
||||
|
||||
Questo modulo estrae e analizza l'EML annidato per identificare
|
||||
il messaggio originale a cui si riferisce la ricevuta.
|
||||
|
||||
Struttura tipica ricevuta Aruba PEC:
|
||||
multipart/mixed
|
||||
├── text/plain (descrizione ricevuta)
|
||||
├── application/xml (daticert.xml – campi strutturati)
|
||||
└── message/rfc822 (messaggio originale)
|
||||
├── From: mittente@pec.it
|
||||
├── To: destinatario@pec.it
|
||||
└── Subject: ...
|
||||
"""
|
||||
|
||||
import email
|
||||
import email.header
|
||||
import email.message
|
||||
import email.utils
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NestedEmlInfo:
|
||||
"""Informazioni estratte dall'EML annidato in una ricevuta PEC."""
|
||||
|
||||
raw_bytes: bytes
|
||||
message_id: str | None
|
||||
subject: str | None
|
||||
from_address: str | None
|
||||
to_addresses: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def extract_nested_eml(msg: email.message.Message) -> NestedEmlInfo | None:
|
||||
"""
|
||||
Estrae l'EML annidato (message/rfc822) da una ricevuta PEC.
|
||||
|
||||
Naviga il messaggio alla ricerca della prima parte di tipo message/rfc822
|
||||
e restituisce le informazioni estratte dall'EML interno.
|
||||
|
||||
Returns:
|
||||
NestedEmlInfo se trovato un EML annidato, None altrimenti.
|
||||
"""
|
||||
if not msg.is_multipart():
|
||||
return None
|
||||
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() != "message/rfc822":
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = part.get_payload()
|
||||
|
||||
inner_bytes: bytes | None = None
|
||||
inner_msg: email.message.Message | None = None
|
||||
|
||||
if isinstance(payload, list) and payload:
|
||||
# Forma canonica RFC: payload è lista di Message
|
||||
candidate = payload[0]
|
||||
if isinstance(candidate, email.message.Message):
|
||||
inner_msg = candidate
|
||||
inner_bytes = candidate.as_bytes()
|
||||
|
||||
elif isinstance(payload, bytes):
|
||||
inner_bytes = payload
|
||||
try:
|
||||
inner_msg = email.message_from_bytes(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif isinstance(payload, str):
|
||||
inner_bytes = payload.encode("utf-8", errors="replace")
|
||||
try:
|
||||
inner_msg = email.message_from_bytes(inner_bytes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if inner_bytes and inner_msg:
|
||||
return _extract_info_from_inner(inner_msg, inner_bytes)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(f"Errore estrazione EML annidato: {exc}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_receipt_xml(msg: email.message.Message) -> str | None:
|
||||
"""
|
||||
Estrae il contenuto XML strutturato da una ricevuta PEC.
|
||||
|
||||
Le ricevute PEC conformi al DM 2 novembre 2005 contengono un allegato XML
|
||||
(tipicamente "daticert.xml") con i campi strutturati della ricevuta:
|
||||
tipo, timestamp, identificatore messaggio, mittente, destinatario, ecc.
|
||||
|
||||
Returns:
|
||||
Stringa XML se trovata, None altrimenti.
|
||||
"""
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
if ct not in ("application/xml", "text/xml"):
|
||||
continue
|
||||
|
||||
# Verifica che sia il daticert (opzionale: controlla filename)
|
||||
filename = part.get_filename() or part.get_param("name") or ""
|
||||
# Accetta qualsiasi XML nelle ricevute PEC (non solo daticert.xml)
|
||||
try:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
except Exception as exc:
|
||||
logger.debug(f"Errore estrazione XML ricevuta (filename={filename!r}): {exc}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ─── Helper privati ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_info_from_inner(
|
||||
msg: email.message.Message,
|
||||
raw_bytes: bytes,
|
||||
) -> NestedEmlInfo:
|
||||
"""Estrae i campi identificativi dall'EML annidato."""
|
||||
|
||||
message_id = msg.get("Message-ID", "").strip() or None
|
||||
subject = _decode_hdr(msg.get("Subject"))
|
||||
from_address = email.utils.parseaddr(msg.get("From", ""))[1] or None
|
||||
to_addresses = _extract_addrs(msg.get("To"))
|
||||
|
||||
return NestedEmlInfo(
|
||||
raw_bytes=raw_bytes,
|
||||
message_id=message_id,
|
||||
subject=subject,
|
||||
from_address=from_address,
|
||||
to_addresses=to_addresses,
|
||||
)
|
||||
|
||||
|
||||
def _decode_hdr(value: str | None) -> str | None:
|
||||
"""Decodifica un header RFC 2047."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parts = email.header.decode_header(value)
|
||||
decoded = []
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
decoded.append(str(part))
|
||||
return "".join(decoded).strip()
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _extract_addrs(field_value: str | None) -> list[str]:
|
||||
"""Estrae lista di indirizzi email da un campo header."""
|
||||
if not field_value:
|
||||
return []
|
||||
try:
|
||||
parsed = email.utils.getaddresses([field_value])
|
||||
return [addr for _, addr in parsed if addr]
|
||||
except Exception:
|
||||
return []
|
||||
Reference in New Issue
Block a user