diff --git a/worker/app/imap/sync.py b/worker/app/imap/sync.py index 80a2aab..c01bc20 100644 --- a/worker/app/imap/sync.py +++ b/worker/app/imap/sync.py @@ -1,20 +1,25 @@ """ -Logica di sincronizzazione messaggi IMAP. +Logica di sincronizzazione messaggi IMAP – Fase 3 aggiornata. Responsabilità: 1. Fetch della lista UID > last_sync_uid 2. Download envelope + raw EML per ogni UID - 3. Parsing base degli header (subject, from, to, date) - 4. Salvataggio in tabella messages + 3. Parsing completo EML tramite app.parsers (Fase 3): + - 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 - 6. Aggiornamento last_sync_uid e last_sync_at sulla mailbox - 7. Pubblicazione evento Redis per notifica WebSocket + 6. Upload allegati su MinIO + inserimento in tabella attachments + 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.header import email.utils -import hashlib import json import logging import re @@ -27,14 +32,16 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings -from app.models import Mailbox, Message -from app.storage.minio_client import upload_eml +from app.models import Attachment, Mailbox, Message +from app.parsers.eml_parser import parse_eml +from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message +from app.storage.minio_client import upload_attachment, upload_eml logger = logging.getLogger(__name__) 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: """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: """ 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() - x_tipo = msg.get("X-TipoRicevuta", "").lower() - - 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") + pec_class = classify_pec_message(msg) + return pec_class.pec_type def _parse_eml(raw_bytes: bytes) -> dict: """ - Parsing di base di un EML – estrae i campi necessari per la tabella messages. - Il parsing completo (body, allegati, EML-in-EML) è in Fase 3. + Parsing di base di un EML. + + 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: - msg = email.message_from_bytes(raw_bytes) - except Exception as e: - logger.warning(f"Errore parsing EML: {e}") - return {} + parsed = parse_eml(raw_bytes) - subject = _decode_header(msg.get("Subject")) - from_addr = email.utils.parseaddr(msg.get("From", ""))[1] or None - to_addrs = _extract_addresses(msg.get("To")) - cc_addrs = _extract_addresses(msg.get("Cc")) - 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 + pec_type = "posta_certificata" + if parsed.raw_message is not None: + pec_class = classify_pec_message(parsed.raw_message) + pec_type = pec_class.pec_type return { - "subject": subject, - "from_address": from_addr, - "to_addresses": to_addrs if to_addrs else None, - "cc_addresses": cc_addrs if cc_addrs else None, - "message_id_header": message_id, - "sent_at": date, + "subject": parsed.subject, + "from_address": parsed.from_address, + "to_addresses": parsed.to_addresses if parsed.to_addresses else None, + "cc_addresses": parsed.cc_addresses if parsed.cc_addresses else None, + "message_id_header": parsed.message_id, + "sent_at": parsed.date, "pec_type": pec_type, - "body_text": body_text, - "body_html": body_html, - "has_attachments": has_attachments, + "body_text": parsed.body_text, + "body_html": parsed.body_html, + "has_attachments": parsed.has_attachments, } @@ -188,8 +140,6 @@ async def sync_new_messages( search_range = f"{last_uid + 1}:*" # ── 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: status, search_data = await imap_client.search("UID", search_range) except Exception as e: @@ -202,7 +152,6 @@ async def sync_new_messages( ) return 0 - # search() restituisce numeri di sequenza (non UID) raw_seqs = b" ".join( d if isinstance(d, bytes) else d.encode() for d in search_data ).decode("ascii", errors="ignore").split() @@ -211,7 +160,6 @@ async def sync_new_messages( if not seq_numbers: return 0 - # Limita il numero di fetch per ciclo seq_numbers = seq_numbers[: settings.imap_max_fetch_per_cycle] logger.info( 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]: """ Fetcha un singolo messaggio per NUMERO DI SEQUENZA (non UID). - Include UID nella richiesta FETCH per estrarlo dalla risposta. Returns: (uid, saved): UID del messaggio e True se salvato, False altrimenti. """ - # FETCH seq (UID RFC822 RFC822.SIZE) try: status, fetch_data = await imap_client.fetch(seq, "(UID RFC822 RFC822.SIZE)") except Exception as e: @@ -277,27 +223,18 @@ async def _fetch_and_save_message_by_seq( ) 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] 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 raw_eml: bytes | None = None size_bytes: int | None = None for item in fetch_data: if isinstance(item, bytearray): - # Questo è il corpo del messaggio EML if len(item) > 200: raw_eml = bytes(item) elif isinstance(item, bytes): - # Risposta header – estrae UID e RFC822.SIZE item_str = item.decode("ascii", errors="ignore") uid_match = re.search(r"UID\s+(\d+)", item_str) if uid_match: @@ -314,7 +251,6 @@ async def _fetch_and_save_message_by_seq( size_bytes = int(size_match.group(1)) if uid is None or uid <= last_uid: - # Questo messaggio ha un UID <= last_uid, non va sincronizzato return uid, False if not raw_eml: @@ -343,7 +279,6 @@ async def _fetch_and_save_message( ) -> bool: """ Fetcha un singolo messaggio per UID (usato dal job sync_mailbox one-shot). - Usa UID FETCH (aioimaplib uid() method). """ existing = await db.execute( 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( uid: int, raw_eml: bytes, @@ -396,9 +333,16 @@ async def _save_message( redis_client: aioredis.Redis, ) -> 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( select(Message.id).where( 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") 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) - # 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 try: eml_path = await upload_eml( @@ -422,9 +381,9 @@ async def _save_message( eml_bytes=raw_eml, ) 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( id=uuid.uuid4(), tenant_id=mailbox.tenant_id, @@ -433,25 +392,35 @@ async def _save_message( imap_folder="INBOX", direction="inbound", state="received", - pec_type=parsed.get("pec_type", "posta_certificata"), - subject=parsed.get("subject"), - from_address=parsed.get("from_address"), - to_addresses=parsed.get("to_addresses"), - cc_addresses=parsed.get("cc_addresses"), - message_id_header=parsed.get("message_id_header"), - sent_at=parsed.get("sent_at"), + pec_type=pec_class.pec_type, + subject=parsed.subject, + from_address=parsed.from_address, + to_addresses=parsed.to_addresses if parsed.to_addresses else None, + cc_addresses=parsed.cc_addresses if parsed.cc_addresses else None, + message_id_header=parsed.message_id, + sent_at=parsed.date, received_at=received_at, size_bytes=size_bytes, - body_text=parsed.get("body_text"), - body_html=parsed.get("body_html"), - has_attachments=parsed.get("has_attachments", False), + body_text=parsed.body_text, + body_html=parsed.body_html, + has_attachments=parsed.has_attachments, + parent_message_id=parent_message_id, raw_eml_path=eml_path, is_read=False, ) 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: event = { "type": "mailbox:new_message", @@ -460,6 +429,7 @@ async def _save_message( "subject": message.subject or "", "from_address": message.from_address or "", "pec_type": message.pec_type, + "is_receipt": pec_class.is_receipt, "received_at": received_at.isoformat(), } await redis_client.publish(f"ws:tenant:{mailbox.tenant_id}", json.dumps(event)) @@ -468,6 +438,107 @@ async def _save_message( logger.info( 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 + + +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}") diff --git a/worker/app/models.py b/worker/app/models.py index bd1555f..8d41e98 100644 --- a/worker/app/models.py +++ b/worker/app/models.py @@ -23,7 +23,7 @@ from sqlalchemy import ( Integer, String, Text, func, ) 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): @@ -126,3 +126,38 @@ class Message(Base): updated_at: Mapped[datetime] = mapped_column( 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") diff --git a/worker/app/parsers/__init__.py b/worker/app/parsers/__init__.py new file mode 100644 index 0000000..7b928e1 --- /dev/null +++ b/worker/app/parsers/__init__.py @@ -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", +] diff --git a/worker/app/parsers/eml_parser.py b/worker/app/parsers/eml_parser.py new file mode 100644 index 0000000..5e0750e --- /dev/null +++ b/worker/app/parsers/eml_parser.py @@ -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 ". + """ + 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}") diff --git a/worker/app/parsers/pec_parser.py b/worker/app/parsers/pec_parser.py new file mode 100644 index 0000000..efb577d --- /dev/null +++ b/worker/app/parsers/pec_parser.py @@ -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 diff --git a/worker/app/parsers/receipt_extractor.py b/worker/app/parsers/receipt_extractor.py new file mode 100644 index 0000000..97960cb --- /dev/null +++ b/worker/app/parsers/receipt_extractor.py @@ -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 [] diff --git a/worker/app/storage/minio_client.py b/worker/app/storage/minio_client.py index d1200b7..dcda481 100644 --- a/worker/app/storage/minio_client.py +++ b/worker/app/storage/minio_client.py @@ -59,6 +59,81 @@ async def upload_eml( 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: """Verifica che il bucket MinIO esista, altrimenti lo crea.""" client = get_minio_client() diff --git a/worker/tests/unit/test_eml_parser.py b/worker/tests/unit/test_eml_parser.py new file mode 100644 index 0000000..025707b --- /dev/null +++ b/worker/tests/unit/test_eml_parser.py @@ -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: +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 + +

Testo 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: +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" + + + +--====receipt==== +Content-Type: message/rfc822 +Content-Disposition: inline + +From: mittente@pec.it +To: destinatario@pec.it +Subject: Test PEC Fase 3 +Message-ID: +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" + + + +--====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" ') + 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 == "" + + 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 "" 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 diff --git a/worker/tests/unit/test_pec_parser.py b/worker/tests/unit/test_pec_parser.py new file mode 100644 index 0000000..c03df5b --- /dev/null +++ b/worker/tests/unit/test_pec_parser.py @@ -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": "", + }) + result = classify_pec_message(msg) + assert result.riferimento_message_id == "" + + 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": " ", + }) + result = classify_pec_message(msg) + assert result.riferimento_message_id == "" + + 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": "", + }) + 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 == "" + 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 diff --git a/worker/tests/unit/test_receipt_extractor.py b/worker/tests/unit/test_receipt_extractor.py new file mode 100644 index 0000000..9848630 --- /dev/null +++ b/worker/tests/unit/test_receipt_extractor.py @@ -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: +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" + + + + + acc-12345 + accettazione + + + +--====acc==== +Content-Type: message/rfc822 +Content-Disposition: inline + +From: mittente@pec.it +To: destinatario@pec.it +Subject: Test PEC originale +Message-ID: +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: +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" + + + +--====cons==== +Content-Type: message/rfc822 +Content-Disposition: inline + +From: mittente@pec.it +To: destinatario@pec.it +Subject: Test PEC originale +Message-ID: +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: +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: +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: +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 == "" + + 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 == "" + + 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 == "" + 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") == "" + + +# ─── 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="", + subject="Test", + from_address="a@pec.it", + to_addresses=["b@pec.it"], + ) + assert info.raw_bytes == b"raw" + assert info.message_id == "" + 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 == "" + + # Estrae EML annidato + nested = extract_nested_eml(msg) + assert nested is not None + assert nested.message_id == "" + + # 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 == "" + + nested = extract_nested_eml(msg) + assert nested is not None + assert nested.message_id == "" + + 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 == "" + + nested = extract_nested_eml(msg) + assert nested is not None + assert nested.message_id == ""