This commit is contained in:
2026-03-18 17:43:03 +01:00
parent d80d912fb3
commit c89c08c397
10 changed files with 2352 additions and 123 deletions
+193 -122
View File
@@ -1,20 +1,25 @@
""" """
Logica di sincronizzazione messaggi IMAP. Logica di sincronizzazione messaggi IMAP Fase 3 aggiornata.
Responsabilità: Responsabilità:
1. Fetch della lista UID > last_sync_uid 1. Fetch della lista UID > last_sync_uid
2. Download envelope + raw EML per ogni UID 2. Download envelope + raw EML per ogni UID
3. Parsing base degli header (subject, from, to, date) 3. Parsing completo EML tramite app.parsers (Fase 3):
4. Salvataggio in tabella messages - Classificazione tipo PEC (X-Ricevuta / X-TipoRicevuta)
- Estrazione allegati (body text/html + allegati file)
- EML-in-EML per ricevute PEC
4. Salvataggio messaggio in tabella messages
5. Upload raw EML su MinIO 5. Upload raw EML su MinIO
6. Aggiornamento last_sync_uid e last_sync_at sulla mailbox 6. Upload allegati su MinIO + inserimento in tabella attachments
7. Pubblicazione evento Redis per notifica WebSocket 7. State machine messaggi outbound (sent→accepted→delivered/anomaly)
tramite X-Riferimento-Message-ID
8. Aggiornamento last_sync_uid e last_sync_at sulla mailbox
9. Pubblicazione evento Redis per notifica WebSocket
""" """
import email import email
import email.header import email.header
import email.utils import email.utils
import hashlib
import json import json
import logging import logging
import re import re
@@ -27,14 +32,16 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings from app.config import get_settings
from app.models import Mailbox, Message from app.models import Attachment, Mailbox, Message
from app.storage.minio_client import upload_eml from app.parsers.eml_parser import parse_eml
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message
from app.storage.minio_client import upload_attachment, upload_eml
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
settings = get_settings() settings = get_settings()
# ─── Helper: decodifica header email ───────────────────────────────────────── # ─── Helper legacy (mantenuti per backward compatibility con i test) ──────────
def _decode_header(header_value: str | None) -> str | None: def _decode_header(header_value: str | None) -> str | None:
"""Decodifica header RFC 2047 (es. =?utf-8?b?...?=) in stringa Python.""" """Decodifica header RFC 2047 (es. =?utf-8?b?...?=) in stringa Python."""
@@ -80,93 +87,38 @@ def _parse_date(date_str: str | None) -> datetime | None:
def _classify_pec_type(msg: email.message.Message) -> str: def _classify_pec_type(msg: email.message.Message) -> str:
""" """
Classifica il tipo PEC dal header X-Ricevuta / X-TipoRicevuta. Classifica il tipo PEC dal header X-Ricevuta / X-TipoRicevuta.
Fase 3 fa il parsing completo; qui classifichiamo al meglio possibile. Mantenuto per backward compatibility usa il parser completo internamente.
""" """
x_ricevuta = msg.get("X-Ricevuta", "").lower() pec_class = classify_pec_message(msg)
x_tipo = msg.get("X-TipoRicevuta", "").lower() return pec_class.pec_type
TYPE_MAP = {
"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",
}
value = x_tipo or x_ricevuta
return TYPE_MAP.get(value, "posta_certificata")
def _parse_eml(raw_bytes: bytes) -> dict: def _parse_eml(raw_bytes: bytes) -> dict:
""" """
Parsing di base di un EML estrae i campi necessari per la tabella messages. Parsing di base di un EML.
Il parsing completo (body, allegati, EML-in-EML) è in Fase 3.
Wrapper del nuovo parser completo (Fase 3).
Mantenuto per backward compatibility con i test esistenti.
Restituisce un dict con i campi base necessari per la tabella messages.
""" """
try: parsed = parse_eml(raw_bytes)
msg = email.message_from_bytes(raw_bytes)
except Exception as e:
logger.warning(f"Errore parsing EML: {e}")
return {}
subject = _decode_header(msg.get("Subject")) pec_type = "posta_certificata"
from_addr = email.utils.parseaddr(msg.get("From", ""))[1] or None if parsed.raw_message is not None:
to_addrs = _extract_addresses(msg.get("To")) pec_class = classify_pec_message(parsed.raw_message)
cc_addrs = _extract_addresses(msg.get("Cc")) pec_type = pec_class.pec_type
message_id = msg.get("Message-ID", "").strip() or None
date = _parse_date(msg.get("Date"))
pec_type = _classify_pec_type(msg)
# Estrazione body text/html (best-effort Fase 3 fa il parsing completo)
body_text = None
body_html = None
has_attachments = False
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
disp = part.get("Content-Disposition", "")
if "attachment" in disp or "inline" in disp:
if part.get_filename():
has_attachments = True
elif ct == "text/plain" and body_text is None:
try:
charset = part.get_content_charset() or "utf-8"
body_text = part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
elif ct == "text/html" and body_html is None:
try:
charset = part.get_content_charset() or "utf-8"
body_html = part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
else:
ct = msg.get_content_type()
try:
charset = msg.get_content_charset() or "utf-8"
payload = msg.get_payload(decode=True)
if payload:
if ct == "text/plain":
body_text = payload.decode(charset, errors="replace")
elif ct == "text/html":
body_html = payload.decode(charset, errors="replace")
except Exception:
pass
return { return {
"subject": subject, "subject": parsed.subject,
"from_address": from_addr, "from_address": parsed.from_address,
"to_addresses": to_addrs if to_addrs else None, "to_addresses": parsed.to_addresses if parsed.to_addresses else None,
"cc_addresses": cc_addrs if cc_addrs else None, "cc_addresses": parsed.cc_addresses if parsed.cc_addresses else None,
"message_id_header": message_id, "message_id_header": parsed.message_id,
"sent_at": date, "sent_at": parsed.date,
"pec_type": pec_type, "pec_type": pec_type,
"body_text": body_text, "body_text": parsed.body_text,
"body_html": body_html, "body_html": parsed.body_html,
"has_attachments": has_attachments, "has_attachments": parsed.has_attachments,
} }
@@ -188,8 +140,6 @@ async def sync_new_messages(
search_range = f"{last_uid + 1}:*" search_range = f"{last_uid + 1}:*"
# ── SEARCH UID > last_sync_uid ───────────────────────────────────────────── # ── SEARCH UID > last_sync_uid ─────────────────────────────────────────────
# aioimaplib non supporta uid('SEARCH',...) → usare search('UID', range)
# che invia "SEARCH UID n:*" e restituisce numeri di sequenza
try: try:
status, search_data = await imap_client.search("UID", search_range) status, search_data = await imap_client.search("UID", search_range)
except Exception as e: except Exception as e:
@@ -202,7 +152,6 @@ async def sync_new_messages(
) )
return 0 return 0
# search() restituisce numeri di sequenza (non UID)
raw_seqs = b" ".join( raw_seqs = b" ".join(
d if isinstance(d, bytes) else d.encode() for d in search_data d if isinstance(d, bytes) else d.encode() for d in search_data
).decode("ascii", errors="ignore").split() ).decode("ascii", errors="ignore").split()
@@ -211,7 +160,6 @@ async def sync_new_messages(
if not seq_numbers: if not seq_numbers:
return 0 return 0
# Limita il numero di fetch per ciclo
seq_numbers = seq_numbers[: settings.imap_max_fetch_per_cycle] seq_numbers = seq_numbers[: settings.imap_max_fetch_per_cycle]
logger.info( logger.info(
f"[{mailbox.email_address}] Trovati {len(seq_numbers)} messaggi nuovi da sincronizzare" f"[{mailbox.email_address}] Trovati {len(seq_numbers)} messaggi nuovi da sincronizzare"
@@ -259,12 +207,10 @@ async def _fetch_and_save_message_by_seq(
) -> tuple[int | None, bool]: ) -> tuple[int | None, bool]:
""" """
Fetcha un singolo messaggio per NUMERO DI SEQUENZA (non UID). Fetcha un singolo messaggio per NUMERO DI SEQUENZA (non UID).
Include UID nella richiesta FETCH per estrarlo dalla risposta.
Returns: Returns:
(uid, saved): UID del messaggio e True se salvato, False altrimenti. (uid, saved): UID del messaggio e True se salvato, False altrimenti.
""" """
# FETCH seq (UID RFC822 RFC822.SIZE)
try: try:
status, fetch_data = await imap_client.fetch(seq, "(UID RFC822 RFC822.SIZE)") status, fetch_data = await imap_client.fetch(seq, "(UID RFC822 RFC822.SIZE)")
except Exception as e: except Exception as e:
@@ -277,27 +223,18 @@ async def _fetch_and_save_message_by_seq(
) )
return None, False return None, False
# Debug: mostra la struttura di fetch_data
items_info = [(type(x).__name__, len(x) if isinstance(x, (bytes, str)) else str(x)) for x in fetch_data] items_info = [(type(x).__name__, len(x) if isinstance(x, (bytes, str)) else str(x)) for x in fetch_data]
logger.debug(f"[{mailbox.email_address}] fetch_data seq {seq}: {items_info}") logger.debug(f"[{mailbox.email_address}] fetch_data seq {seq}: {items_info}")
# Estrae UID, raw EML e size dalla risposta.
# NOTA CRITICA: aioimaplib restituisce il corpo EML come `bytearray` (non `bytes`)!
# [0] bytes → FETCH response header con UID e RFC822.SIZE
# [1] bytearray → raw EML (il corpo del messaggio)
# [2] bytes → ')' (chiusura)
# [3] bytes → riga OK finale
uid: int | None = None uid: int | None = None
raw_eml: bytes | None = None raw_eml: bytes | None = None
size_bytes: int | None = None size_bytes: int | None = None
for item in fetch_data: for item in fetch_data:
if isinstance(item, bytearray): if isinstance(item, bytearray):
# Questo è il corpo del messaggio EML
if len(item) > 200: if len(item) > 200:
raw_eml = bytes(item) raw_eml = bytes(item)
elif isinstance(item, bytes): elif isinstance(item, bytes):
# Risposta header estrae UID e RFC822.SIZE
item_str = item.decode("ascii", errors="ignore") item_str = item.decode("ascii", errors="ignore")
uid_match = re.search(r"UID\s+(\d+)", item_str) uid_match = re.search(r"UID\s+(\d+)", item_str)
if uid_match: if uid_match:
@@ -314,7 +251,6 @@ async def _fetch_and_save_message_by_seq(
size_bytes = int(size_match.group(1)) size_bytes = int(size_match.group(1))
if uid is None or uid <= last_uid: if uid is None or uid <= last_uid:
# Questo messaggio ha un UID <= last_uid, non va sincronizzato
return uid, False return uid, False
if not raw_eml: if not raw_eml:
@@ -343,7 +279,6 @@ async def _fetch_and_save_message(
) -> bool: ) -> bool:
""" """
Fetcha un singolo messaggio per UID (usato dal job sync_mailbox one-shot). Fetcha un singolo messaggio per UID (usato dal job sync_mailbox one-shot).
Usa UID FETCH (aioimaplib uid() method).
""" """
existing = await db.execute( existing = await db.execute(
select(Message.id).where( select(Message.id).where(
@@ -387,6 +322,8 @@ async def _fetch_and_save_message(
) )
# ─── Save message (Fase 3 con parser completo, allegati, state machine) ────
async def _save_message( async def _save_message(
uid: int, uid: int,
raw_eml: bytes, raw_eml: bytes,
@@ -396,9 +333,16 @@ async def _save_message(
redis_client: aioredis.Redis, redis_client: aioredis.Redis,
) -> bool: ) -> bool:
""" """
Salva un messaggio EML in DB e su MinIO. Pubblica evento WebSocket. Salva un messaggio EML in DB e su MinIO.
Fase 3 aggiornato per:
- Parser completo (body, allegati, EML-in-EML)
- Classificazione precisa tipo PEC (tutti i provider)
- Salvataggio allegati su MinIO + tabella attachments
- State machine outbound: aggiorna stato messaggio originale alla ricezione ricevuta
- Collegamento parent_message_id via X-Riferimento-Message-ID
""" """
# Idempotenza # ── Idempotenza ───────────────────────────────────────────────────────────
existing = await db.execute( existing = await db.execute(
select(Message.id).where( select(Message.id).where(
Message.mailbox_id == mailbox.id, Message.mailbox_id == mailbox.id,
@@ -409,10 +353,25 @@ async def _save_message(
logger.debug(f"[{mailbox.email_address}] UID {uid} già in DB, skip") logger.debug(f"[{mailbox.email_address}] UID {uid} già in DB, skip")
return False return False
parsed = _parse_eml(raw_eml) # ── Parsing completo EML ──────────────────────────────────────────────────
parsed = parse_eml(raw_eml)
pec_class = classify_pec_message(
parsed.raw_message or email.message_from_bytes(raw_eml)
)
received_at = datetime.now(UTC) received_at = datetime.now(UTC)
# Upload su MinIO # ── State machine: trova e aggiorna messaggio outbound ────────────────────
parent_message_id: uuid.UUID | None = None
if pec_class.is_receipt and pec_class.riferimento_message_id:
parent_message_id = await _apply_outbound_state_machine(
riferimento_message_id=pec_class.riferimento_message_id,
pec_type=pec_class.pec_type,
tenant_id=mailbox.tenant_id,
db=db,
)
# ── Upload raw EML su MinIO ───────────────────────────────────────────────
eml_path: str | None = None eml_path: str | None = None
try: try:
eml_path = await upload_eml( eml_path = await upload_eml(
@@ -422,9 +381,9 @@ async def _save_message(
eml_bytes=raw_eml, eml_bytes=raw_eml,
) )
except Exception as e: except Exception as e:
logger.error(f"[{mailbox.email_address}] Upload MinIO UID {uid}: {e}") logger.error(f"[{mailbox.email_address}] Upload EML MinIO UID {uid}: {e}")
# Salva in DB # ── Salva messaggio in DB ─────────────────────────────────────────────────
message = Message( message = Message(
id=uuid.uuid4(), id=uuid.uuid4(),
tenant_id=mailbox.tenant_id, tenant_id=mailbox.tenant_id,
@@ -433,25 +392,35 @@ async def _save_message(
imap_folder="INBOX", imap_folder="INBOX",
direction="inbound", direction="inbound",
state="received", state="received",
pec_type=parsed.get("pec_type", "posta_certificata"), pec_type=pec_class.pec_type,
subject=parsed.get("subject"), subject=parsed.subject,
from_address=parsed.get("from_address"), from_address=parsed.from_address,
to_addresses=parsed.get("to_addresses"), to_addresses=parsed.to_addresses if parsed.to_addresses else None,
cc_addresses=parsed.get("cc_addresses"), cc_addresses=parsed.cc_addresses if parsed.cc_addresses else None,
message_id_header=parsed.get("message_id_header"), message_id_header=parsed.message_id,
sent_at=parsed.get("sent_at"), sent_at=parsed.date,
received_at=received_at, received_at=received_at,
size_bytes=size_bytes, size_bytes=size_bytes,
body_text=parsed.get("body_text"), body_text=parsed.body_text,
body_html=parsed.get("body_html"), body_html=parsed.body_html,
has_attachments=parsed.get("has_attachments", False), has_attachments=parsed.has_attachments,
parent_message_id=parent_message_id,
raw_eml_path=eml_path, raw_eml_path=eml_path,
is_read=False, is_read=False,
) )
db.add(message) db.add(message)
await db.flush() await db.flush() # ottieni message.id prima di salvare gli allegati
# Pubblica evento Redis per WebSocket # ── Salva allegati su MinIO + tabella attachments ─────────────────────────
if parsed.attachments:
await _save_attachments(
attachments=parsed.attachments,
message=message,
mailbox=mailbox,
db=db,
)
# ── Pubblica evento Redis per WebSocket ───────────────────────────────────
try: try:
event = { event = {
"type": "mailbox:new_message", "type": "mailbox:new_message",
@@ -460,6 +429,7 @@ async def _save_message(
"subject": message.subject or "", "subject": message.subject or "",
"from_address": message.from_address or "", "from_address": message.from_address or "",
"pec_type": message.pec_type, "pec_type": message.pec_type,
"is_receipt": pec_class.is_receipt,
"received_at": received_at.isoformat(), "received_at": received_at.isoformat(),
} }
await redis_client.publish(f"ws:tenant:{mailbox.tenant_id}", json.dumps(event)) await redis_client.publish(f"ws:tenant:{mailbox.tenant_id}", json.dumps(event))
@@ -468,6 +438,107 @@ async def _save_message(
logger.info( logger.info(
f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} " f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} "
f"subject={message.subject!r} pec_type={message.pec_type}" f"pec_type={pec_class.pec_type!r} "
f"subject={message.subject!r} "
f"allegati={len(parsed.attachments)}"
) )
return True return True
async def _apply_outbound_state_machine(
riferimento_message_id: str,
pec_type: str,
tenant_id: uuid.UUID,
db: AsyncSession,
) -> uuid.UUID | None:
"""
Aggiorna lo stato del messaggio outbound originale in base alla ricevuta.
Cerca il messaggio outbound con message_id_header == riferimento_message_id,
applica la transizione di stato se valida.
Returns:
UUID del messaggio originale se trovato, None altrimenti.
"""
result = await db.execute(
select(Message).where(
Message.tenant_id == tenant_id,
Message.message_id_header == riferimento_message_id,
Message.direction == "outbound",
)
)
parent_msg = result.scalar_one_or_none()
if not parent_msg:
logger.debug(
f"Messaggio outbound non trovato per riferimento={riferimento_message_id!r} "
f"(potrebbe essere stato inviato da client diverso)"
)
return None
new_state = apply_outbound_transition(parent_msg.state, pec_type)
if new_state:
old_state = parent_msg.state
parent_msg.state = new_state
parent_msg.updated_at = datetime.now(UTC)
await db.flush()
logger.info(
f"State machine outbound: {riferimento_message_id!r} "
f"{old_state!r}{new_state!r} (ricevuta: {pec_type!r})"
)
return parent_msg.id
async def _save_attachments(
attachments: list,
message: Message,
mailbox: Mailbox,
db: AsyncSession,
) -> None:
"""
Carica gli allegati su MinIO e inserisce i record in tabella attachments.
Args:
attachments: lista di AttachmentInfo dal parser EML
message: messaggio DB a cui appartengono gli allegati
mailbox: casella (per il path MinIO)
db: sessione DB
"""
for att in attachments:
storage_path: str | None = None
try:
storage_path = await upload_attachment(
tenant_id=str(mailbox.tenant_id),
mailbox_id=str(mailbox.id),
message_id=str(message.id),
filename=att.filename,
content=att.content,
content_type=att.content_type,
)
except Exception as e:
logger.error(
f"Upload allegato {att.filename!r} per messaggio {message.id}: {e}"
)
# Continua con gli altri allegati anche se uno fallisce
continue
# Inserisci record in DB
att_record = Attachment(
id=uuid.uuid4(),
tenant_id=message.tenant_id,
message_id=message.id,
filename=att.filename,
content_type=att.content_type,
size_bytes=att.size_bytes,
storage_path=storage_path,
checksum_sha256=att.checksum_sha256,
)
db.add(att_record)
# flush per persistere tutti gli allegati nella transazione corrente
try:
await db.flush()
except Exception as e:
logger.error(f"Errore flush allegati per messaggio {message.id}: {e}")
+36 -1
View File
@@ -23,7 +23,7 @@ from sqlalchemy import (
Integer, String, Text, func, Integer, String, Text, func,
) )
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase): class Base(DeclarativeBase):
@@ -126,3 +126,38 @@ class Message(Base):
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
) )
# Relazione con allegati (usata dal worker per inserimento)
attachments: Mapped[list["Attachment"]] = relationship(
"Attachment", back_populates="message", cascade="all, delete-orphan"
)
class Attachment(Base):
"""
Allegato di un messaggio PEC.
Corrisponde alla tabella `attachments` nel DB.
"""
__tablename__ = "attachments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="CASCADE"),
nullable=False,
)
filename: Mapped[str] = mapped_column(String(512), nullable=False)
content_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# Relazione inversa verso Message
message: Mapped["Message"] = relationship("Message", back_populates="attachments")
+34
View File
@@ -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",
]
+361
View File
@@ -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}")
+192
View File
@@ -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
+174
View File
@@ -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 []
+75
View File
@@ -59,6 +59,81 @@ async def upload_eml(
raise raise
async def upload_attachment(
tenant_id: str,
mailbox_id: str,
message_id: str,
filename: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> str:
"""
Carica un allegato su MinIO e restituisce il percorso oggetto.
Percorso: tenants/{tenant_id}/mailboxes/{mailbox_id}/attachments/{message_id}/{filename}
Args:
tenant_id: UUID del tenant
mailbox_id: UUID della casella
message_id: UUID del messaggio a cui appartiene l'allegato
filename: nome file dell'allegato (usato nel path)
content: byte del file
content_type: MIME type del file
Returns:
Percorso oggetto su MinIO (senza il nome bucket)
"""
client = get_minio_client()
bucket = settings.minio_bucket
# Sanitizza il filename per evitare path traversal
safe_filename = _sanitize_filename(filename)
object_path = (
f"tenants/{tenant_id}/mailboxes/{mailbox_id}"
f"/attachments/{message_id}/{safe_filename}"
)
try:
data_stream = io.BytesIO(content)
await client.put_object(
bucket_name=bucket,
object_name=object_path,
data=data_stream,
length=len(content),
content_type=content_type,
)
logger.debug(
f"Allegato caricato: s3://{bucket}/{object_path} "
f"({len(content)} bytes, {content_type})"
)
return object_path
except Exception as e:
logger.error(f"Errore upload allegato {object_path}: {e}")
raise
def _sanitize_filename(filename: str) -> str:
"""
Sanitizza il nome file per uso sicuro come path MinIO.
Rimuove caratteri pericolosi mantenendo l'estensione originale.
"""
import re
# Rimuovi path separators e null bytes
safe = filename.replace("/", "_").replace("\\", "_").replace("\x00", "")
# Rimuovi caratteri non ASCII e di controllo
safe = re.sub(r"[^\w.\-() ]", "_", safe, flags=re.UNICODE)
# Limita la lunghezza (MinIO ha limite 1024 chars per object name)
if len(safe) > 200:
# Mantieni estensione
parts = safe.rsplit(".", 1)
if len(parts) == 2:
safe = parts[0][:196] + "." + parts[1]
else:
safe = safe[:200]
return safe or "attachment"
async def ensure_bucket_exists() -> None: async def ensure_bucket_exists() -> None:
"""Verifica che il bucket MinIO esista, altrimenti lo crea.""" """Verifica che il bucket MinIO esista, altrimenti lo crea."""
client = get_minio_client() client = get_minio_client()
+509
View File
@@ -0,0 +1,509 @@
"""
Test unitari per app.parsers.eml_parser.
Copertura:
- Parsing header (Subject, From, To, Cc, Message-ID, Date)
- Decodifica RFC 2047 (UTF-8, ISO-8859-1, base64, quoted-printable)
- Estrazione body text/plain e text/html
- Estrazione allegati (singoli e multipli)
- Gestione EML-in-EML (message/rfc822)
- Flag has_attachments (solo allegati non-PEC-system)
- Allegati PEC di sistema (daticert.xml, postacert.eml)
- EML vuoto / malformato (no crash)
- Messaggio non-multipart
"""
import email
import textwrap
import pytest
from app.parsers.eml_parser import (
AttachmentInfo,
ParsedEmail,
decode_header,
extract_addresses,
parse_date,
parse_eml,
)
# ─── Fixture EML ──────────────────────────────────────────────────────────────
SIMPLE_EML = b"""\
From: mittente@pec.it
To: destinatario@pec.it
Cc: copia@pec.it
Subject: Test PEC Fase 3
Message-ID: <test123@pec.it>
Date: Wed, 18 Mar 2026 14:00:00 +0100
Content-Type: text/plain; charset=utf-8
Corpo del messaggio di test.
Seconda riga.
"""
MULTIPART_EML = b"""\
From: mittente@pec.it
To: dest@pec.it
Subject: PEC con allegato
Date: Wed, 18 Mar 2026 10:00:00 +0100
Content-Type: multipart/mixed; boundary="====boundary123===="
--====boundary123====
Content-Type: text/plain; charset=utf-8
Testo del messaggio.
--====boundary123====
Content-Type: application/pdf; name="documento.pdf"
Content-Disposition: attachment; filename="documento.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQ=
--====boundary123====--
"""
MULTIPART_HTML_EML = b"""\
From: mittente@pec.it
To: dest@pec.it
Subject: PEC multipart/alternative
Date: Wed, 18 Mar 2026 10:00:00 +0100
Content-Type: multipart/alternative; boundary="====alt===="
--====alt====
Content-Type: text/plain; charset=utf-8
Testo piano.
--====alt====
Content-Type: text/html; charset=utf-8
<html><body><p>Testo HTML.</p></body></html>
--====alt====--
"""
RECEIPT_EML_WITH_NESTED = b"""\
From: posta-certificata@pec.aruba.it
To: mittente@pec.it
Subject: CONSEGNA: Test PEC Fase 3
X-Ricevuta: avvenuta-consegna
X-Riferimento-Message-ID: <orig001@pec.it>
Date: Wed, 18 Mar 2026 14:05:00 +0100
Content-Type: multipart/mixed; boundary="====receipt===="
--====receipt====
Content-Type: text/plain; charset=utf-8
Il messaggio e' stato consegnato al destinatario.
--====receipt====
Content-Type: application/xml; name="daticert.xml"
Content-Disposition: attachment; filename="daticert.xml"
<?xml version="1.0"?><PostaCertificata versione="2.3"></PostaCertificata>
--====receipt====
Content-Type: message/rfc822
Content-Disposition: inline
From: mittente@pec.it
To: destinatario@pec.it
Subject: Test PEC Fase 3
Message-ID: <orig001@pec.it>
Date: Wed, 18 Mar 2026 14:00:00 +0100
Content-Type: text/plain; charset=utf-8
Corpo del messaggio originale.
--====receipt====--
"""
MULTIPLE_ATTACHMENTS_EML = b"""\
From: a@pec.it
To: b@pec.it
Subject: PEC con allegati multipli
Date: Wed, 18 Mar 2026 10:00:00 +0100
Content-Type: multipart/mixed; boundary="====multi===="
--====multi====
Content-Type: text/plain; charset=utf-8
Corpo.
--====multi====
Content-Type: application/pdf; name="doc1.pdf"
Content-Disposition: attachment; filename="doc1.pdf"
Content-Transfer-Encoding: base64
AAEC
--====multi====
Content-Type: application/pdf; name="doc2.pdf"
Content-Disposition: attachment; filename="doc2.pdf"
Content-Transfer-Encoding: base64
BAEC
--====multi====--
"""
PEC_SYSTEM_EML = b"""\
From: posta-certificata@pec.aruba.it
To: mittente@pec.it
Subject: ACCETTAZIONE: Test
X-Ricevuta: accettazione
Date: Wed, 18 Mar 2026 14:01:00 +0100
Content-Type: multipart/mixed; boundary="====sys===="
--====sys====
Content-Type: text/plain; charset=utf-8
Ricevuta di accettazione.
--====sys====
Content-Type: application/xml; name="daticert.xml"
Content-Disposition: attachment; filename="daticert.xml"
<?xml version="1.0"?><PostaCertificata versione="2.3"></PostaCertificata>
--====sys====
Content-Type: message/rfc822
Content-Disposition: inline; filename="postacert.eml"
From: mittente@pec.it
To: dest@pec.it
Subject: Test originale
Corpo.
--====sys====--
"""
# ─── Test decode_header ───────────────────────────────────────────────────────
class TestDecodeHeader:
def test_stringa_semplice(self):
assert decode_header("Hello World") == "Hello World"
def test_none_ritorna_none(self):
assert decode_header(None) is None
def test_stringa_vuota_ritorna_none(self):
assert decode_header("") is None
def test_utf8_base64(self):
# "PEC test" in base64 UTF-8
encoded = "=?utf-8?b?UEVDIHRlc3Q=?="
assert decode_header(encoded) == "PEC test"
def test_iso8859_quoted_printable(self):
# "Multa n. 123" in QP ISO-8859-1
encoded = "=?iso-8859-1?q?Multa_n=2E_123?="
result = decode_header(encoded)
assert result is not None
assert "Multa" in result
assert "123" in result
def test_multipart_header(self):
# Header con più parti encodate
encoded = "=?utf-8?b?UEVD?= =?utf-8?b?IHRlc3Q=?="
result = decode_header(encoded)
assert result is not None
assert "PEC" in result
def test_stringa_gia_decodificata(self):
assert decode_header("Oggetto normale") == "Oggetto normale"
# ─── Test extract_addresses ───────────────────────────────────────────────────
class TestExtractAddresses:
def test_singolo_indirizzo(self):
addrs = extract_addresses("test@example.com")
assert "test@example.com" in addrs
def test_multipli_indirizzi(self):
addrs = extract_addresses("a@x.com, b@y.com, c@z.com")
assert len(addrs) == 3
assert "a@x.com" in addrs
assert "b@y.com" in addrs
assert "c@z.com" in addrs
def test_display_name(self):
addrs = extract_addresses('"Mario Rossi" <mario@comune.it>')
assert "mario@comune.it" in addrs
def test_none_ritorna_lista_vuota(self):
assert extract_addresses(None) == []
def test_stringa_vuota_ritorna_lista_vuota(self):
assert extract_addresses("") == []
# ─── Test parse_date ──────────────────────────────────────────────────────────
class TestParseDate:
def test_data_valida(self):
d = parse_date("Wed, 18 Mar 2026 14:00:00 +0100")
assert d is not None
assert d.year == 2026
assert d.month == 3
assert d.day == 18
def test_none_ritorna_none(self):
assert parse_date(None) is None
def test_stringa_invalida_ritorna_none(self):
assert parse_date("non-una-data") is None
def test_data_senza_timezone_aggiunge_utc(self):
d = parse_date("18 Mar 2026 14:00:00 +0000")
assert d is not None
assert d.tzinfo is not None
# ─── Test parse_eml messaggio semplice ──────────────────────────────────────
class TestParseEmlSimple:
def test_subject(self):
p = parse_eml(SIMPLE_EML)
assert p.subject == "Test PEC Fase 3"
def test_from_address(self):
p = parse_eml(SIMPLE_EML)
assert p.from_address == "mittente@pec.it"
def test_to_addresses(self):
p = parse_eml(SIMPLE_EML)
assert "destinatario@pec.it" in p.to_addresses
def test_cc_addresses(self):
p = parse_eml(SIMPLE_EML)
assert "copia@pec.it" in p.cc_addresses
def test_message_id(self):
p = parse_eml(SIMPLE_EML)
assert p.message_id == "<test123@pec.it>"
def test_date(self):
p = parse_eml(SIMPLE_EML)
assert p.date is not None
assert p.date.year == 2026
def test_body_text(self):
p = parse_eml(SIMPLE_EML)
assert p.body_text is not None
assert "Corpo del messaggio" in p.body_text
assert "Seconda riga" in p.body_text
def test_no_html(self):
p = parse_eml(SIMPLE_EML)
assert p.body_html is None
def test_no_attachments(self):
p = parse_eml(SIMPLE_EML)
assert p.attachments == []
assert p.has_attachments is False
def test_raw_message_presente(self):
p = parse_eml(SIMPLE_EML)
assert p.raw_message is not None
assert isinstance(p.raw_message, email.message.Message)
# ─── Test parse_eml multipart con allegato ──────────────────────────────────
class TestParseEmlMultipart:
def test_body_text_estratto(self):
p = parse_eml(MULTIPART_EML)
assert p.body_text is not None
assert "Testo del messaggio" in p.body_text
def test_allegato_trovato(self):
p = parse_eml(MULTIPART_EML)
assert len(p.attachments) == 1
att = p.attachments[0]
assert att.filename == "documento.pdf"
assert att.content_type == "application/pdf"
assert att.size_bytes > 0
assert att.checksum_sha256 is not None
assert len(att.checksum_sha256) == 64
def test_has_attachments_true(self):
p = parse_eml(MULTIPART_EML)
assert p.has_attachments is True
def test_allegati_multipli(self):
p = parse_eml(MULTIPLE_ATTACHMENTS_EML)
filenames = [a.filename for a in p.attachments]
assert "doc1.pdf" in filenames
assert "doc2.pdf" in filenames
assert len(p.attachments) == 2
# ─── Test parse_eml multipart/alternative ───────────────────────────────────
class TestParseEmlAlternative:
def test_body_text_e_html(self):
p = parse_eml(MULTIPART_HTML_EML)
assert p.body_text is not None
assert "Testo piano" in p.body_text
assert p.body_html is not None
assert "<html>" in p.body_html
assert "Testo HTML" in p.body_html
def test_no_attachments_in_alternative(self):
p = parse_eml(MULTIPART_HTML_EML)
assert p.has_attachments is False
# ─── Test parse_eml ricevuta con EML-in-EML ────────────────────────────────
class TestParseEmlReceiptWithNested:
def test_body_text_ricevuta(self):
p = parse_eml(RECEIPT_EML_WITH_NESTED)
assert p.body_text is not None
assert "consegnato" in p.body_text.lower()
def test_allegato_xml_daticert(self):
p = parse_eml(RECEIPT_EML_WITH_NESTED)
filenames = [a.filename for a in p.attachments]
assert "daticert.xml" in filenames
def test_allegato_xml_e_pec_system(self):
p = parse_eml(RECEIPT_EML_WITH_NESTED)
xml_att = next(a for a in p.attachments if a.filename == "daticert.xml")
assert xml_att.is_pec_system is True
def test_eml_annidato_trovato(self):
"""Il messaggio originale annidato deve essere presente come allegato."""
p = parse_eml(RECEIPT_EML_WITH_NESTED)
eml_atts = [a for a in p.attachments if a.content_type == "message/rfc822"]
assert len(eml_atts) >= 1
def test_has_attachments_false_quando_solo_system(self):
"""has_attachments deve essere False se ci sono solo allegati PEC di sistema."""
p = parse_eml(PEC_SYSTEM_EML)
# daticert.xml e postacert.eml sono entrambi system → has_attachments = False
assert p.has_attachments is False
def test_allegati_sistema_marcati_correttamente(self):
p = parse_eml(PEC_SYSTEM_EML)
for att in p.attachments:
if att.filename in ("daticert.xml", "postacert.eml"):
assert att.is_pec_system is True, f"{att.filename} dovrebbe essere is_pec_system=True"
# ─── Test parse_eml edge cases ─────────────────────────────────────────────
class TestParseEmlEdgeCases:
def test_eml_vuoto_no_eccezione(self):
p = parse_eml(b"")
assert isinstance(p, ParsedEmail)
assert p.subject is None
assert p.body_text is None
assert p.attachments == []
def test_eml_malformato_no_eccezione(self):
p = parse_eml(b"questo non e' un EML valido\x00\xFF")
assert isinstance(p, ParsedEmail)
def test_headers_mancanti(self):
raw = b"Content-Type: text/plain\r\n\r\nSolo corpo."
p = parse_eml(raw)
assert p.subject is None
assert p.from_address is None
assert p.to_addresses == []
def test_body_con_encoding_windows1252(self):
raw = (
b"From: a@pec.it\r\nTo: b@pec.it\r\n"
b"Content-Type: text/plain; charset=windows-1252\r\n\r\n"
b"Buonagiornata\xe0 tutti"
)
p = parse_eml(raw)
assert p.body_text is not None
assert "Buonagiornata" in p.body_text
def test_attachments_senza_filename_ignorati(self):
"""
Un part senza filename non deve essere aggiunto come allegato
se non è text/plain o text/html.
"""
raw = (
b"From: a@pec.it\r\nTo: b@pec.it\r\n"
b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n'
b"--B\r\nContent-Type: text/plain\r\n\r\nBody\r\n"
b"--B\r\nContent-Type: application/octet-stream\r\n"
b"Content-Disposition: attachment\r\n\r\nDATA\r\n"
b"--B--\r\n"
)
p = parse_eml(raw)
# L'allegato senza filename non deve comparire
for att in p.attachments:
assert att.filename is not None and att.filename != ""
def test_checksum_sha256_corretto(self):
"""Il checksum SHA-256 dell'allegato deve essere valido."""
import hashlib
p = parse_eml(MULTIPART_EML)
assert len(p.attachments) == 1
att = p.attachments[0]
expected = hashlib.sha256(att.content).hexdigest()
assert att.checksum_sha256 == expected
# ─── Test AttachmentInfo dataclass ────────────────────────────────────────────
class TestAttachmentInfoDataclass:
def test_campi_base(self):
import hashlib
content = b"test content"
att = AttachmentInfo(
filename="test.pdf",
content_type="application/pdf",
content=content,
size_bytes=len(content),
checksum_sha256=hashlib.sha256(content).hexdigest(),
)
assert att.filename == "test.pdf"
assert att.content_type == "application/pdf"
assert att.size_bytes == 12
assert att.is_inline is False
assert att.is_pec_system is False
def test_inline_flag(self):
import hashlib
content = b"img"
att = AttachmentInfo(
filename="img.png",
content_type="image/png",
content=content,
size_bytes=len(content),
checksum_sha256=hashlib.sha256(content).hexdigest(),
is_inline=True,
)
assert att.is_inline is True
+385
View File
@@ -0,0 +1,385 @@
"""
Test unitari per app.parsers.pec_parser.
Copertura:
- Tutti i tipi di ricevuta PEC (accettazione, avvenuta-consegna, ecc.)
- Varianti per provider: Aruba, Namirial, Legalmail/Poste, InfoCert
- Precedenza X-TipoRicevuta su X-Ricevuta
- Estrazione X-Riferimento-Message-ID
- State machine outbound (apply_outbound_transition)
- Funzione get_state_transition per tutti i tipi
"""
import email
import pytest
from app.parsers.pec_parser import (
VALID_OUTBOUND_TRANSITIONS,
PecClassification,
apply_outbound_transition,
classify_pec_message,
get_state_transition,
)
# ─── Helper per costruire messaggi di test ────────────────────────────────────
def make_msg(headers: dict[str, str], body: str = "") -> email.message.Message:
"""Costruisce un email.message.Message con gli header dati."""
raw = ""
for k, v in headers.items():
raw += f"{k}: {v}\r\n"
raw += f"\r\n{body}"
return email.message_from_string(raw)
# ─── Test classificazione tipi ricevuta (provider standard / Aruba) ───────────
class TestArubaProvider:
"""Header con trattini, stile Aruba PEC (provider più diffuso)."""
def test_accettazione(self):
msg = make_msg({"X-Ricevuta": "accettazione"})
result = classify_pec_message(msg)
assert result.pec_type == "accettazione"
assert result.is_receipt is True
def test_avvenuta_consegna(self):
msg = make_msg({"X-Ricevuta": "avvenuta-consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "avvenuta_consegna"
assert result.is_receipt is True
def test_mancata_consegna(self):
msg = make_msg({"X-Ricevuta": "mancata-consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "mancata_consegna"
assert result.is_receipt is True
def test_non_accettazione(self):
msg = make_msg({"X-Ricevuta": "non-accettazione"})
result = classify_pec_message(msg)
assert result.pec_type == "non_accettazione"
assert result.is_receipt is True
def test_presa_in_carico(self):
msg = make_msg({"X-Ricevuta": "presa-in-carico"})
result = classify_pec_message(msg)
assert result.pec_type == "presa_in_carico"
assert result.is_receipt is True
def test_errore_consegna(self):
msg = make_msg({"X-Ricevuta": "errore-consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "errore_consegna"
assert result.is_receipt is True
def test_preavviso_mancata_consegna(self):
msg = make_msg({"X-Ricevuta": "preavviso-mancata-consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "preavviso_mancata_consegna"
assert result.is_receipt is True
def test_rilevazione_virus(self):
msg = make_msg({"X-Ricevuta": "rilevazione-virus"})
result = classify_pec_message(msg)
assert result.pec_type == "rilevazione_virus"
assert result.is_receipt is True
def test_posta_certificata_assenza_header(self):
"""Messaggio PEC originale: nessun header X-Ricevuta."""
msg = make_msg({
"From": "mittente@pec.it",
"To": "destinatario@pec.it",
"Subject": "Test PEC",
})
result = classify_pec_message(msg)
assert result.pec_type == "posta_certificata"
assert result.is_receipt is False
def test_posta_certificata_esplicita(self):
"""X-Ricevuta: posta-certificata (usato da Aruba per indicare il messaggio stesso)."""
msg = make_msg({"X-Ricevuta": "posta-certificata"})
result = classify_pec_message(msg)
assert result.pec_type == "posta_certificata"
assert result.is_receipt is False
# ─── Test provider Namirial (usa X-TipoRicevuta) ─────────────────────────────
class TestNamirialProvider:
"""Namirial usa X-TipoRicevuta invece di X-Ricevuta."""
def test_x_tipo_ricevuta_avvenuta_consegna(self):
msg = make_msg({"X-TipoRicevuta": "avvenuta-consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "avvenuta_consegna"
assert result.x_tipo_ricevuta == "avvenuta-consegna"
def test_x_tipo_ricevuta_accettazione(self):
msg = make_msg({"X-TipoRicevuta": "accettazione"})
result = classify_pec_message(msg)
assert result.pec_type == "accettazione"
def test_x_tipo_ricevuta_ha_precedenza_su_x_ricevuta(self):
"""X-TipoRicevuta deve avere precedenza su X-Ricevuta."""
msg = make_msg({
"X-Ricevuta": "accettazione",
"X-TipoRicevuta": "avvenuta-consegna",
})
result = classify_pec_message(msg)
assert result.pec_type == "avvenuta_consegna" # X-TipoRicevuta vince
def test_mancata_consegna(self):
msg = make_msg({"X-TipoRicevuta": "mancata-consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "mancata_consegna"
# ─── Test provider Legalmail/Poste (varianti underscore e spazi) ──────────────
class TestLegalmailProvider:
"""Legalmail/Poste Italiane può usare underscore o spazi nei valori."""
def test_avvenuta_consegna_underscore(self):
msg = make_msg({"X-Ricevuta": "avvenuta_consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "avvenuta_consegna"
def test_non_accettazione_underscore(self):
msg = make_msg({"X-Ricevuta": "non_accettazione"})
result = classify_pec_message(msg)
assert result.pec_type == "non_accettazione"
def test_mancata_consegna_spazio(self):
msg = make_msg({"X-Ricevuta": "mancata consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "mancata_consegna"
def test_avvenuta_consegna_spazio(self):
msg = make_msg({"X-Ricevuta": "avvenuta consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "avvenuta_consegna"
def test_presa_in_carico_underscore(self):
msg = make_msg({"X-Ricevuta": "presa_in_carico"})
result = classify_pec_message(msg)
assert result.pec_type == "presa_in_carico"
def test_abbreviazione_consegna(self):
"""Legalmail usa talvolta solo 'consegna' come abbreviazione."""
msg = make_msg({"X-Ricevuta": "consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "avvenuta_consegna"
def test_rilevazione_virus_underscore(self):
msg = make_msg({"X-Ricevuta": "rilevazione_virus"})
result = classify_pec_message(msg)
assert result.pec_type == "rilevazione_virus"
# ─── Test case insensitivity ──────────────────────────────────────────────────
class TestCaseInsensitivity:
"""I valori degli header PEC devono essere trattati case-insensitive."""
def test_uppercase_accettazione(self):
msg = make_msg({"X-Ricevuta": "ACCETTAZIONE"})
result = classify_pec_message(msg)
assert result.pec_type == "accettazione"
def test_mixed_case_avvenuta_consegna(self):
msg = make_msg({"X-Ricevuta": "Avvenuta-Consegna"})
result = classify_pec_message(msg)
assert result.pec_type == "avvenuta_consegna"
def test_uppercase_x_tipo(self):
msg = make_msg({"X-TipoRicevuta": "MANCATA-CONSEGNA"})
result = classify_pec_message(msg)
assert result.pec_type == "mancata_consegna"
# ─── Test X-Riferimento-Message-ID ───────────────────────────────────────────
class TestRiferimentoMessageId:
"""Il campo X-Riferimento-Message-ID collega la ricevuta al messaggio originale."""
def test_estrazione_riferimento(self):
msg = make_msg({
"X-Ricevuta": "avvenuta-consegna",
"X-Riferimento-Message-ID": "<msg123@pec.it>",
})
result = classify_pec_message(msg)
assert result.riferimento_message_id == "<msg123@pec.it>"
def test_riferimento_assente(self):
msg = make_msg({"X-Ricevuta": "accettazione"})
result = classify_pec_message(msg)
assert result.riferimento_message_id is None
def test_posta_certificata_senza_riferimento(self):
"""I messaggi originali non hanno X-Riferimento-Message-ID."""
msg = make_msg({"From": "mittente@pec.it", "To": "dest@pec.it"})
result = classify_pec_message(msg)
assert result.riferimento_message_id is None
def test_riferimento_con_spazi_extra(self):
"""Il valore deve essere pulito da spazi iniziali/finali."""
msg = make_msg({
"X-Ricevuta": "accettazione",
"X-Riferimento-Message-ID": " <msg456@pec.it> ",
})
result = classify_pec_message(msg)
assert result.riferimento_message_id == "<msg456@pec.it>"
def test_x_raw_headers_conservati(self):
"""I valori raw degli header devono essere conservati nel risultato."""
msg = make_msg({
"X-Ricevuta": "accettazione",
"X-TipoRicevuta": "avvenuta-consegna",
})
result = classify_pec_message(msg)
assert result.x_ricevuta == "accettazione"
assert result.x_tipo_ricevuta == "avvenuta-consegna"
def test_tipo_sconosciuto_mappa_posta_certificata(self):
"""Valori sconosciuti devono mappare a posta_certificata."""
msg = make_msg({"X-Ricevuta": "valore-sconosciuto"})
result = classify_pec_message(msg)
assert result.pec_type == "posta_certificata"
assert result.is_receipt is False
# ─── Test state machine outbound ─────────────────────────────────────────────
class TestStateMachine:
"""Test della state machine per messaggi outbound."""
# ── get_state_transition ──────────────────────────────────────────────────
def test_accettazione_produce_accepted(self):
assert get_state_transition("accettazione") == "accepted"
def test_presa_in_carico_produce_accepted(self):
assert get_state_transition("presa_in_carico") == "accepted"
def test_avvenuta_consegna_produce_delivered(self):
assert get_state_transition("avvenuta_consegna") == "delivered"
def test_non_accettazione_produce_anomaly(self):
assert get_state_transition("non_accettazione") == "anomaly"
def test_mancata_consegna_produce_anomaly(self):
assert get_state_transition("mancata_consegna") == "anomaly"
def test_errore_consegna_produce_anomaly(self):
assert get_state_transition("errore_consegna") == "anomaly"
def test_preavviso_mancata_produce_anomaly(self):
assert get_state_transition("preavviso_mancata_consegna") == "anomaly"
def test_rilevazione_virus_produce_anomaly(self):
assert get_state_transition("rilevazione_virus") == "anomaly"
def test_posta_certificata_nessuna_transizione(self):
"""posta_certificata non è una ricevuta: nessuna transizione."""
assert get_state_transition("posta_certificata") is None
def test_tipo_sconosciuto_nessuna_transizione(self):
assert get_state_transition("unknown") is None
# ── apply_outbound_transition ─────────────────────────────────────────────
def test_sent_plus_accettazione_da_accepted(self):
assert apply_outbound_transition("sent", "accettazione") == "accepted"
def test_queued_plus_accettazione_da_accepted(self):
assert apply_outbound_transition("queued", "accettazione") == "accepted"
def test_accepted_plus_avvenuta_consegna_da_delivered(self):
assert apply_outbound_transition("accepted", "avvenuta_consegna") == "delivered"
def test_sent_plus_avvenuta_consegna_non_valido(self):
"""Non si può saltare direttamente da sent a delivered."""
assert apply_outbound_transition("sent", "avvenuta_consegna") is None
def test_delivered_terminal(self):
"""delivered è uno stato terminale: nessuna transizione possibile."""
assert apply_outbound_transition("delivered", "avvenuta_consegna") is None
assert apply_outbound_transition("delivered", "mancata_consegna") is None
def test_anomaly_terminal(self):
"""anomaly è uno stato terminale: nessuna transizione possibile."""
assert apply_outbound_transition("anomaly", "avvenuta_consegna") is None
def test_sent_plus_mancata_consegna_da_anomaly(self):
assert apply_outbound_transition("sent", "mancata_consegna") == "anomaly"
def test_accepted_plus_non_accettazione_da_anomaly(self):
assert apply_outbound_transition("accepted", "non_accettazione") == "anomaly"
def test_posta_certificata_nessun_effetto(self):
"""posta_certificata non è una ricevuta: nessuna transizione."""
assert apply_outbound_transition("sent", "posta_certificata") is None
# ── VALID_OUTBOUND_TRANSITIONS struttura ──────────────────────────────────
def test_valid_transitions_queued(self):
assert "accepted" in VALID_OUTBOUND_TRANSITIONS["queued"]
assert "anomaly" in VALID_OUTBOUND_TRANSITIONS["queued"]
assert "delivered" not in VALID_OUTBOUND_TRANSITIONS["queued"]
def test_valid_transitions_sent(self):
assert "accepted" in VALID_OUTBOUND_TRANSITIONS["sent"]
assert "anomaly" in VALID_OUTBOUND_TRANSITIONS["sent"]
def test_valid_transitions_accepted(self):
assert "delivered" in VALID_OUTBOUND_TRANSITIONS["accepted"]
assert "anomaly" in VALID_OUTBOUND_TRANSITIONS["accepted"]
assert "accepted" not in VALID_OUTBOUND_TRANSITIONS["accepted"]
def test_delivered_non_in_valid_transitions(self):
"""delivered è terminale: non deve comparire come chiave."""
assert "delivered" not in VALID_OUTBOUND_TRANSITIONS
def test_anomaly_non_in_valid_transitions(self):
"""anomaly è terminale: non deve comparire come chiave."""
assert "anomaly" not in VALID_OUTBOUND_TRANSITIONS
# ─── Test PecClassification dataclass ────────────────────────────────────────
class TestPecClassificationDataclass:
"""Test dei campi del dataclass PecClassification."""
def test_tutti_i_campi_presenti(self):
msg = make_msg({
"X-Ricevuta": "accettazione",
"X-TipoRicevuta": "avvenuta-consegna",
"X-Riferimento-Message-ID": "<ref@pec.it>",
})
result = classify_pec_message(msg)
assert isinstance(result, PecClassification)
assert result.pec_type == "avvenuta_consegna"
assert result.is_receipt is True
assert result.riferimento_message_id == "<ref@pec.it>"
assert result.x_ricevuta == "accettazione"
assert result.x_tipo_ricevuta == "avvenuta-consegna"
def test_messaggio_puro_tutti_none(self):
msg = make_msg({"From": "a@pec.it", "To": "b@pec.it"})
result = classify_pec_message(msg)
assert result.pec_type == "posta_certificata"
assert result.is_receipt is False
assert result.riferimento_message_id is None
assert result.x_ricevuta is None
assert result.x_tipo_ricevuta is None
+393
View File
@@ -0,0 +1,393 @@
"""
Test unitari per app.parsers.receipt_extractor.
Copertura:
- Estrazione EML annidato (message/rfc822) da ricevuta PEC
- Campi estratti dall'EML annidato (message_id, subject, from, to)
- Estrazione XML strutturato (daticert.xml)
- Messaggio senza EML annidato (ritorna None)
- Messaggio non-multipart (ritorna None)
- Payload come lista di Message (forma canonica RFC)
- Payload come bytes/stringa
- Ricevute di tutti i tipi (accettazione, avvenuta-consegna, ecc.)
"""
import email
import pytest
from app.parsers.receipt_extractor import (
NestedEmlInfo,
extract_nested_eml,
extract_receipt_xml,
)
# ─── Fixture EML ──────────────────────────────────────────────────────────────
# Ricevuta di accettazione (Aruba style) con EML annidato
ACCETTAZIONE_EML = b"""\
From: posta-certificata@pec.aruba.it
To: mittente@pec.it
Subject: ACCETTAZIONE: Test PEC
X-Ricevuta: accettazione
X-Riferimento-Message-ID: <orig-001@pec.it>
Date: Wed, 18 Mar 2026 14:01:00 +0100
Content-Type: multipart/mixed; boundary="====acc===="
--====acc====
Content-Type: text/plain; charset=utf-8
Il messaggio e' stato accettato dal gestore.
--====acc====
Content-Type: application/xml; name="daticert.xml"
Content-Disposition: attachment; filename="daticert.xml"
<?xml version="1.0" encoding="UTF-8"?>
<PostaCertificata versione="2.3">
<Intestazione>
<Identificativo>acc-12345</Identificativo>
<TipoRicevuta>accettazione</TipoRicevuta>
</Intestazione>
</PostaCertificata>
--====acc====
Content-Type: message/rfc822
Content-Disposition: inline
From: mittente@pec.it
To: destinatario@pec.it
Subject: Test PEC originale
Message-ID: <orig-001@pec.it>
Date: Wed, 18 Mar 2026 14:00:00 +0100
Content-Type: text/plain; charset=utf-8
Corpo del messaggio originale inviato.
--====acc====--
"""
# Ricevuta di avvenuta consegna con EML annidato
AVVENUTA_CONSEGNA_EML = b"""\
From: posta-certificata@pec.aruba.it
To: mittente@pec.it
Subject: CONSEGNA: Test PEC
X-Ricevuta: avvenuta-consegna
X-Riferimento-Message-ID: <orig-002@pec.it>
Date: Wed, 18 Mar 2026 14:10:00 +0100
Content-Type: multipart/mixed; boundary="====cons===="
--====cons====
Content-Type: text/plain; charset=utf-8
Il messaggio e' stato consegnato al destinatario.
--====cons====
Content-Type: application/xml; name="daticert.xml"
Content-Disposition: attachment; filename="daticert.xml"
<?xml version="1.0"?><PostaCertificata versione="2.3"/>
--====cons====
Content-Type: message/rfc822
Content-Disposition: inline
From: mittente@pec.it
To: destinatario@pec.it
Subject: Test PEC originale
Message-ID: <orig-002@pec.it>
Date: Wed, 18 Mar 2026 14:00:00 +0100
Content-Type: text/plain; charset=utf-8
Corpo originale.
--====cons====--
"""
# Messaggio PEC originale (non ricevuta, senza EML annidato)
POSTA_CERTIFICATA_EML = b"""\
From: mittente@pec.it
To: destinatario@pec.it
Subject: Messaggio PEC originale
Message-ID: <msg-123@pec.it>
Date: Wed, 18 Mar 2026 14:00:00 +0100
Content-Type: text/plain; charset=utf-8
Questo e' il corpo del messaggio PEC.
"""
# Messaggio multipart senza EML annidato
MULTIPART_NO_NESTED = b"""\
From: a@pec.it
To: b@pec.it
Subject: PEC con allegato PDF (nessun EML annidato)
Date: Wed, 18 Mar 2026 10:00:00 +0100
Content-Type: multipart/mixed; boundary="====notest===="
--====notest====
Content-Type: text/plain; charset=utf-8
Corpo.
--====notest====
Content-Type: application/pdf; name="doc.pdf"
Content-Disposition: attachment; filename="doc.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQ=
--====notest====--
"""
# Ricevuta di mancata consegna con EML annidato (Namirial style)
MANCATA_CONSEGNA_EML = b"""\
From: no-reply@namirial.com
To: mittente@pec.it
Subject: MANCATA CONSEGNA: Test
X-TipoRicevuta: mancata-consegna
X-Riferimento-Message-ID: <orig-003@pec.namirial.it>
Date: Wed, 18 Mar 2026 15:00:00 +0100
Content-Type: multipart/mixed; boundary="====mc===="
--====mc====
Content-Type: text/plain; charset=utf-8
Il messaggio non e' stato consegnato.
--====mc====
Content-Type: message/rfc822
Content-Disposition: inline
From: mittente@pec.namirial.it
To: destinatario@pec.it
Subject: Messaggio non consegnato
Message-ID: <orig-003@pec.namirial.it>
Date: Wed, 18 Mar 2026 14:30:00 +0100
Content-Type: text/plain; charset=utf-8
Corpo originale che non e' stato consegnato.
--====mc====--
"""
# ─── Test extract_nested_eml ──────────────────────────────────────────────────
class TestExtractNestedEml:
def test_accettazione_eml_annidato_trovato(self):
msg = email.message_from_bytes(ACCETTAZIONE_EML)
result = extract_nested_eml(msg)
assert result is not None
def test_accettazione_message_id(self):
msg = email.message_from_bytes(ACCETTAZIONE_EML)
result = extract_nested_eml(msg)
assert result is not None
assert result.message_id == "<orig-001@pec.it>"
def test_accettazione_subject(self):
msg = email.message_from_bytes(ACCETTAZIONE_EML)
result = extract_nested_eml(msg)
assert result is not None
assert result.subject == "Test PEC originale"
def test_accettazione_from_address(self):
msg = email.message_from_bytes(ACCETTAZIONE_EML)
result = extract_nested_eml(msg)
assert result is not None
assert result.from_address == "mittente@pec.it"
def test_accettazione_to_addresses(self):
msg = email.message_from_bytes(ACCETTAZIONE_EML)
result = extract_nested_eml(msg)
assert result is not None
assert "destinatario@pec.it" in result.to_addresses
def test_accettazione_raw_bytes_non_vuoti(self):
msg = email.message_from_bytes(ACCETTAZIONE_EML)
result = extract_nested_eml(msg)
assert result is not None
assert len(result.raw_bytes) > 0
def test_avvenuta_consegna_eml_trovato(self):
msg = email.message_from_bytes(AVVENUTA_CONSEGNA_EML)
result = extract_nested_eml(msg)
assert result is not None
assert result.message_id == "<orig-002@pec.it>"
def test_mancata_consegna_namirial(self):
"""Test con ricevuta Namirial (X-TipoRicevuta)."""
msg = email.message_from_bytes(MANCATA_CONSEGNA_EML)
result = extract_nested_eml(msg)
assert result is not None
assert result.message_id == "<orig-003@pec.namirial.it>"
assert result.subject == "Messaggio non consegnato"
def test_messaggio_non_multipart_ritorna_none(self):
"""Un messaggio non-multipart non può avere EML annidato."""
msg = email.message_from_bytes(POSTA_CERTIFICATA_EML)
result = extract_nested_eml(msg)
assert result is None
def test_multipart_senza_nested_ritorna_none(self):
"""Un multipart senza parti message/rfc822 ritorna None."""
msg = email.message_from_bytes(MULTIPART_NO_NESTED)
result = extract_nested_eml(msg)
assert result is None
def test_risultato_e_nested_eml_info(self):
msg = email.message_from_bytes(ACCETTAZIONE_EML)
result = extract_nested_eml(msg)
assert isinstance(result, NestedEmlInfo)
def test_raw_bytes_e_valido_eml(self):
"""I raw_bytes estratti devono essere un EML valido (ri-parsabile)."""
msg = email.message_from_bytes(ACCETTAZIONE_EML)
result = extract_nested_eml(msg)
assert result is not None
# Verifica che i bytes siano ri-parsabili
inner = email.message_from_bytes(result.raw_bytes)
assert inner.get("Message-ID") == "<orig-001@pec.it>"
# ─── Test extract_receipt_xml ─────────────────────────────────────────────────
class TestExtractReceiptXml:
def test_daticert_xml_estratto(self):
msg = email.message_from_bytes(ACCETTAZIONE_EML)
xml = extract_receipt_xml(msg)
assert xml is not None
assert "PostaCertificata" in xml
def test_xml_con_contenuto(self):
msg = email.message_from_bytes(ACCETTAZIONE_EML)
xml = extract_receipt_xml(msg)
assert xml is not None
assert "accettazione" in xml.lower()
def test_xml_avvenuta_consegna(self):
msg = email.message_from_bytes(AVVENUTA_CONSEGNA_EML)
xml = extract_receipt_xml(msg)
assert xml is not None
assert "PostaCertificata" in xml
def test_messaggio_senza_xml_ritorna_none(self):
"""Un messaggio PEC senza allegato XML ritorna None."""
msg = email.message_from_bytes(POSTA_CERTIFICATA_EML)
xml = extract_receipt_xml(msg)
assert xml is None
def test_multipart_senza_xml_ritorna_none(self):
"""Multipart con solo PDF, senza XML."""
msg = email.message_from_bytes(MULTIPART_NO_NESTED)
xml = extract_receipt_xml(msg)
assert xml is None
def test_mancata_consegna_senza_xml_ritorna_none(self):
"""Alcune ricevute (Namirial) non hanno daticert.xml."""
msg = email.message_from_bytes(MANCATA_CONSEGNA_EML)
xml = extract_receipt_xml(msg)
# Questa ricevuta non ha XML allegato → None
assert xml is None
# ─── Test NestedEmlInfo dataclass ─────────────────────────────────────────────
class TestNestedEmlInfoDataclass:
def test_campi_base(self):
info = NestedEmlInfo(
raw_bytes=b"raw",
message_id="<test@pec.it>",
subject="Test",
from_address="a@pec.it",
to_addresses=["b@pec.it"],
)
assert info.raw_bytes == b"raw"
assert info.message_id == "<test@pec.it>"
assert info.subject == "Test"
assert info.from_address == "a@pec.it"
assert "b@pec.it" in info.to_addresses
def test_to_addresses_default_lista_vuota(self):
info = NestedEmlInfo(
raw_bytes=b"",
message_id=None,
subject=None,
from_address=None,
)
assert info.to_addresses == []
def test_campi_opzionali_possono_essere_none(self):
info = NestedEmlInfo(
raw_bytes=b"data",
message_id=None,
subject=None,
from_address=None,
)
assert info.message_id is None
assert info.subject is None
assert info.from_address is None
# ─── Test integrazione pec_parser + receipt_extractor ────────────────────────
class TestIntegrationPecParserAndExtractor:
"""
Verifica che le informazioni estratte da receipt_extractor corrispondano
al X-Riferimento-Message-ID usato da pec_parser per collegare i messaggi.
"""
def test_riferimento_corrisponde_a_message_id_annidato(self):
"""
X-Riferimento-Message-ID nella ricevuta deve corrispondere
al Message-ID dell'EML annidato.
"""
from app.parsers.pec_parser import classify_pec_message
msg = email.message_from_bytes(ACCETTAZIONE_EML)
# Classifica la ricevuta
pec_class = classify_pec_message(msg)
assert pec_class.riferimento_message_id == "<orig-001@pec.it>"
# Estrae EML annidato
nested = extract_nested_eml(msg)
assert nested is not None
assert nested.message_id == "<orig-001@pec.it>"
# Deve corrispondere
assert pec_class.riferimento_message_id == nested.message_id
def test_avvenuta_consegna_corrispondenza_completa(self):
from app.parsers.pec_parser import classify_pec_message
msg = email.message_from_bytes(AVVENUTA_CONSEGNA_EML)
pec_class = classify_pec_message(msg)
assert pec_class.pec_type == "avvenuta_consegna"
assert pec_class.riferimento_message_id == "<orig-002@pec.it>"
nested = extract_nested_eml(msg)
assert nested is not None
assert nested.message_id == "<orig-002@pec.it>"
def test_mancata_consegna_namirial_corrispondenza(self):
from app.parsers.pec_parser import classify_pec_message
msg = email.message_from_bytes(MANCATA_CONSEGNA_EML)
pec_class = classify_pec_message(msg)
assert pec_class.pec_type == "mancata_consegna"
assert pec_class.riferimento_message_id == "<orig-003@pec.namirial.it>"
nested = extract_nested_eml(msg)
assert nested is not None
assert nested.message_id == "<orig-003@pec.namirial.it>"