mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
fase 3
This commit is contained in:
+193
-122
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Parsers PEC – Fase 3.
|
||||||
|
|
||||||
|
Esporta i moduli principali per il parsing di messaggi PEC:
|
||||||
|
- eml_parser: parsing MIME completo (body, allegati, header)
|
||||||
|
- pec_parser: classificazione tipo PEC da header X-Ricevuta / X-TipoRicevuta
|
||||||
|
- receipt_extractor: estrazione EML annidato nelle ricevute (EML-in-EML)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.parsers.eml_parser import AttachmentInfo, ParsedEmail, parse_eml
|
||||||
|
from app.parsers.pec_parser import (
|
||||||
|
PecClassification,
|
||||||
|
VALID_OUTBOUND_TRANSITIONS,
|
||||||
|
classify_pec_message,
|
||||||
|
get_state_transition,
|
||||||
|
)
|
||||||
|
from app.parsers.receipt_extractor import (
|
||||||
|
NestedEmlInfo,
|
||||||
|
extract_nested_eml,
|
||||||
|
extract_receipt_xml,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AttachmentInfo",
|
||||||
|
"ParsedEmail",
|
||||||
|
"parse_eml",
|
||||||
|
"PecClassification",
|
||||||
|
"VALID_OUTBOUND_TRANSITIONS",
|
||||||
|
"classify_pec_message",
|
||||||
|
"get_state_transition",
|
||||||
|
"NestedEmlInfo",
|
||||||
|
"extract_nested_eml",
|
||||||
|
"extract_receipt_xml",
|
||||||
|
]
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""
|
||||||
|
Parser MIME completo per messaggi email/PEC (Fase 3).
|
||||||
|
|
||||||
|
Responsabilità:
|
||||||
|
- Parsing MIME ricorsivo (multipart, nested)
|
||||||
|
- Estrazione body text e html
|
||||||
|
- Estrazione allegati con filename, content_type, sha256
|
||||||
|
- Decodifica header RFC 2047 (=?utf-8?b?...?=)
|
||||||
|
- Gestione parti message/rfc822 (EML-in-EML per ricevute PEC)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import email
|
||||||
|
import email.header
|
||||||
|
import email.message
|
||||||
|
import email.utils
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ─── Data classes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AttachmentInfo:
|
||||||
|
"""Dati estratti da un singolo allegato MIME."""
|
||||||
|
|
||||||
|
filename: str
|
||||||
|
content_type: str
|
||||||
|
content: bytes
|
||||||
|
size_bytes: int
|
||||||
|
checksum_sha256: str
|
||||||
|
is_inline: bool = False # True per parti inline (immagini nel corpo HTML)
|
||||||
|
is_pec_system: bool = False # True per file di infrastruttura PEC (daticert.xml, postacert.eml)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedEmail:
|
||||||
|
"""Risultato completo del parsing di un EML."""
|
||||||
|
|
||||||
|
subject: str | None = None
|
||||||
|
from_address: str | None = None
|
||||||
|
to_addresses: list[str] = field(default_factory=list)
|
||||||
|
cc_addresses: list[str] = field(default_factory=list)
|
||||||
|
message_id: str | None = None
|
||||||
|
date: datetime | None = None
|
||||||
|
body_text: str | None = None
|
||||||
|
body_html: str | None = None
|
||||||
|
attachments: list[AttachmentInfo] = field(default_factory=list)
|
||||||
|
has_attachments: bool = False
|
||||||
|
|
||||||
|
# Riferimento all'oggetto email.message.Message originale (per parser PEC)
|
||||||
|
raw_message: email.message.Message | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Funzioni helper (pubbliche, usate anche dai test) ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def decode_header(value: str | None) -> str | None:
|
||||||
|
"""
|
||||||
|
Decodifica un header RFC 2047 (=?charset?encoding?text?=) in stringa Python.
|
||||||
|
|
||||||
|
Esempi:
|
||||||
|
=?utf-8?b?UEVDIHRlc3Q=?= → "PEC test"
|
||||||
|
=?iso-8859-1?q?Multa_n=2E_123?= → "Multa n. 123"
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parts = email.header.decode_header(value)
|
||||||
|
decoded = []
|
||||||
|
for part, charset in parts:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
decoded.append(str(part))
|
||||||
|
return "".join(decoded).strip()
|
||||||
|
except Exception:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_addresses(field_value: str | None) -> list[str]:
|
||||||
|
"""
|
||||||
|
Estrae una lista di indirizzi email da un campo header To/Cc/From.
|
||||||
|
|
||||||
|
Gestisce sia "addr@example.com" sia "Nome <addr@example.com>".
|
||||||
|
"""
|
||||||
|
if not field_value:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = email.utils.getaddresses([field_value])
|
||||||
|
return [addr for _, addr in parsed if addr]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(date_str: str | None) -> datetime | None:
|
||||||
|
"""
|
||||||
|
Converte una stringa data RFC 2822 (header Date) in datetime con timezone.
|
||||||
|
|
||||||
|
Ritorna None in caso di stringa non valida.
|
||||||
|
"""
|
||||||
|
if not date_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = email.utils.parsedate_to_datetime(date_str)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=UTC)
|
||||||
|
return parsed
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Parser principale ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def parse_eml(raw_bytes: bytes) -> ParsedEmail:
|
||||||
|
"""
|
||||||
|
Parsing completo di un raw EML.
|
||||||
|
|
||||||
|
Estrae:
|
||||||
|
- Header: Subject, From, To, Cc, Message-ID, Date
|
||||||
|
- Body: text/plain, text/html (primo trovato per tipo)
|
||||||
|
- Allegati: tutti i parti con filename, inclusi message/rfc822
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_bytes: byte del messaggio EML grezzo
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ParsedEmail con tutti i campi estratti (fields None/[] se non presenti)
|
||||||
|
"""
|
||||||
|
if not raw_bytes:
|
||||||
|
return ParsedEmail()
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = email.message_from_bytes(raw_bytes)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Errore parsing EML bytes: {exc}")
|
||||||
|
return ParsedEmail()
|
||||||
|
|
||||||
|
result = ParsedEmail()
|
||||||
|
result.raw_message = msg
|
||||||
|
|
||||||
|
# ── Header ────────────────────────────────────────────────────────────────
|
||||||
|
result.subject = decode_header(msg.get("Subject"))
|
||||||
|
result.from_address = email.utils.parseaddr(msg.get("From", ""))[1] or None
|
||||||
|
result.to_addresses = extract_addresses(msg.get("To"))
|
||||||
|
result.cc_addresses = extract_addresses(msg.get("Cc"))
|
||||||
|
result.message_id = msg.get("Message-ID", "").strip() or None
|
||||||
|
result.date = parse_date(msg.get("Date"))
|
||||||
|
|
||||||
|
# ── Body e allegati ───────────────────────────────────────────────────────
|
||||||
|
if msg.is_multipart():
|
||||||
|
_walk_parts(msg, result)
|
||||||
|
else:
|
||||||
|
_extract_single_part_body(msg, result)
|
||||||
|
|
||||||
|
result.has_attachments = any(
|
||||||
|
not att.is_pec_system for att in result.attachments
|
||||||
|
) if result.attachments else False
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helper privati ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
# Nomi file usati dall'infrastruttura PEC (non allegati utente)
|
||||||
|
_PEC_SYSTEM_FILENAMES = frozenset({
|
||||||
|
"daticert.xml",
|
||||||
|
"postacert.eml",
|
||||||
|
"smime.p7s",
|
||||||
|
"smime.p7m",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Content-type usati dall'infrastruttura PEC
|
||||||
|
_PEC_SYSTEM_CONTENT_TYPES = frozenset({
|
||||||
|
"application/pkcs7-signature",
|
||||||
|
"application/pkcs7-mime",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pec_system_part(filename: str | None, content_type: str) -> bool:
|
||||||
|
"""Determina se un allegato è parte dell'infrastruttura PEC."""
|
||||||
|
if filename and filename.lower() in _PEC_SYSTEM_FILENAMES:
|
||||||
|
return True
|
||||||
|
if content_type in _PEC_SYSTEM_CONTENT_TYPES:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_filename(part: email.message.Message) -> str | None:
|
||||||
|
"""
|
||||||
|
Estrae il filename da un part MIME.
|
||||||
|
|
||||||
|
Prova nell'ordine:
|
||||||
|
1. Content-Disposition: attachment; filename="..."
|
||||||
|
2. Content-Type: ...; name="..."
|
||||||
|
"""
|
||||||
|
filename = part.get_filename()
|
||||||
|
if filename:
|
||||||
|
return decode_header(filename)
|
||||||
|
name = part.get_param("name")
|
||||||
|
if name:
|
||||||
|
return decode_header(name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_parts(msg: email.message.Message, result: ParsedEmail) -> None:
|
||||||
|
"""
|
||||||
|
Naviga ricorsivamente tutti i part MIME del messaggio.
|
||||||
|
|
||||||
|
Logica:
|
||||||
|
- multipart/* → salta (è un container, i figli vengono visitati nel loop)
|
||||||
|
- text/plain → body_text (primo trovato, se non è "attachment")
|
||||||
|
- text/html → body_html (primo trovato, se non è "attachment")
|
||||||
|
- message/rfc822 → allegato di tipo EML-in-EML
|
||||||
|
- tutto il resto con filename → allegato
|
||||||
|
"""
|
||||||
|
for part in msg.walk():
|
||||||
|
# Salta container multipart
|
||||||
|
if part.get_content_maintype() == "multipart":
|
||||||
|
continue
|
||||||
|
|
||||||
|
ct = part.get_content_type()
|
||||||
|
disp = part.get("Content-Disposition", "")
|
||||||
|
filename = _get_filename(part)
|
||||||
|
|
||||||
|
# ── EML-in-EML (message/rfc822) ───────────────────────────────────────
|
||||||
|
if ct == "message/rfc822":
|
||||||
|
_extract_eml_in_eml(part, filename, result)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Allegato esplicito (Content-Disposition: attachment) ──────────────
|
||||||
|
if "attachment" in disp:
|
||||||
|
if filename:
|
||||||
|
_extract_attachment(part, filename, ct, is_inline=False, result=result)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Parte inline con filename (es. immagine embeds in HTML) ───────────
|
||||||
|
if "inline" in disp and filename:
|
||||||
|
_extract_attachment(part, filename, ct, is_inline=True, result=result)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Body text/plain ───────────────────────────────────────────────────
|
||||||
|
if ct == "text/plain" and result.body_text is None:
|
||||||
|
body = _decode_payload(part)
|
||||||
|
if body is not None:
|
||||||
|
result.body_text = body
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Body text/html ────────────────────────────────────────────────────
|
||||||
|
if ct == "text/html" and result.body_html is None:
|
||||||
|
body = _decode_payload(part)
|
||||||
|
if body is not None:
|
||||||
|
result.body_html = body
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Altri tipi con filename → allegato ────────────────────────────────
|
||||||
|
if filename:
|
||||||
|
_extract_attachment(part, filename, ct, is_inline=False, result=result)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_single_part_body(msg: email.message.Message, result: ParsedEmail) -> None:
|
||||||
|
"""Estrae il body per messaggi non-multipart."""
|
||||||
|
ct = msg.get_content_type()
|
||||||
|
body = _decode_payload(msg)
|
||||||
|
if body is None:
|
||||||
|
return
|
||||||
|
if ct == "text/plain":
|
||||||
|
result.body_text = body
|
||||||
|
elif ct == "text/html":
|
||||||
|
result.body_html = body
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_payload(part: email.message.Message) -> str | None:
|
||||||
|
"""Decodifica il payload di una parte MIME come stringa testo."""
|
||||||
|
try:
|
||||||
|
raw = part.get_payload(decode=True)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
return raw.decode(charset, errors="replace")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"Errore decodifica payload {part.get_content_type()}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_eml_in_eml(
|
||||||
|
part: email.message.Message,
|
||||||
|
filename: str | None,
|
||||||
|
result: ParsedEmail,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Estrae il messaggio EML annidato in un part message/rfc822.
|
||||||
|
|
||||||
|
Nella struttura PEC, questo è tipicamente il messaggio originale
|
||||||
|
allegato alle ricevute di consegna/accettazione.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = part.get_payload()
|
||||||
|
|
||||||
|
inner_bytes: bytes | None = None
|
||||||
|
|
||||||
|
if isinstance(payload, list) and payload:
|
||||||
|
# Forma canonica: payload è lista di Message
|
||||||
|
inner_msg = payload[0]
|
||||||
|
if isinstance(inner_msg, email.message.Message):
|
||||||
|
inner_bytes = inner_msg.as_bytes()
|
||||||
|
|
||||||
|
elif isinstance(payload, bytes):
|
||||||
|
inner_bytes = payload
|
||||||
|
|
||||||
|
elif isinstance(payload, str):
|
||||||
|
inner_bytes = payload.encode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if inner_bytes:
|
||||||
|
eff_filename = filename or "inner_message.eml"
|
||||||
|
is_system = _is_pec_system_part(eff_filename, "message/rfc822")
|
||||||
|
att = AttachmentInfo(
|
||||||
|
filename=eff_filename,
|
||||||
|
content_type="message/rfc822",
|
||||||
|
content=inner_bytes,
|
||||||
|
size_bytes=len(inner_bytes),
|
||||||
|
checksum_sha256=hashlib.sha256(inner_bytes).hexdigest(),
|
||||||
|
is_inline=False,
|
||||||
|
is_pec_system=is_system,
|
||||||
|
)
|
||||||
|
result.attachments.append(att)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Errore estrazione EML-in-EML: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_attachment(
|
||||||
|
part: email.message.Message,
|
||||||
|
filename: str,
|
||||||
|
content_type: str,
|
||||||
|
is_inline: bool,
|
||||||
|
result: ParsedEmail,
|
||||||
|
) -> None:
|
||||||
|
"""Estrae il contenuto di un allegato e lo aggiunge al ParsedEmail."""
|
||||||
|
try:
|
||||||
|
content = part.get_payload(decode=True)
|
||||||
|
if content is None:
|
||||||
|
return
|
||||||
|
is_system = _is_pec_system_part(filename, content_type)
|
||||||
|
att = AttachmentInfo(
|
||||||
|
filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
content=content,
|
||||||
|
size_bytes=len(content),
|
||||||
|
checksum_sha256=hashlib.sha256(content).hexdigest(),
|
||||||
|
is_inline=is_inline,
|
||||||
|
is_pec_system=is_system,
|
||||||
|
)
|
||||||
|
result.attachments.append(att)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Errore estrazione allegato {filename!r}: {exc}")
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Parser specifico per header PEC (Fase 3).
|
||||||
|
|
||||||
|
Classifica il tipo di messaggio PEC leggendo gli header:
|
||||||
|
- X-Ricevuta → tipo ricevuta (Aruba, Legalmail)
|
||||||
|
- X-TipoRicevuta → tipo ricevuta alternativo (Namirial)
|
||||||
|
- X-Riferimento-Message-ID → Message-ID del messaggio originale
|
||||||
|
- X-Trasporto → indicatore trasporto PEC (presenza indica PEC)
|
||||||
|
|
||||||
|
Il risultato della classificazione determina:
|
||||||
|
- `pec_type`: valore enum DB (posta_certificata, accettazione, ...)
|
||||||
|
- `is_receipt`: True se il messaggio è una ricevuta (non l'originale)
|
||||||
|
- `riferimento_message_id`: usato per collegare parent_message_id
|
||||||
|
|
||||||
|
State machine messaggi outbound:
|
||||||
|
sent/queued → accepted (ricevuta di accettazione o presa in carico)
|
||||||
|
accepted → delivered (ricevuta di avvenuta consegna)
|
||||||
|
any valid → anomaly (non-accettazione, mancata consegna, errore, virus)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import email.message
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Mapping header PEC → enum DB ────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# I diversi provider PEC usano varianti nei valori degli header:
|
||||||
|
# Aruba PEC: X-Ricevuta con trattini (es. "avvenuta-consegna")
|
||||||
|
# Namirial: X-TipoRicevuta (stessa sintassi di Aruba)
|
||||||
|
# Legalmail: X-Ricevuta, talvolta con underscore o spazi
|
||||||
|
# InfoCert: X-Ricevuta, stessa sintassi Aruba
|
||||||
|
|
||||||
|
_PEC_TYPE_MAP: dict[str, str] = {
|
||||||
|
# ── Standard con trattini (Aruba, Namirial, InfoCert) ──────────────────
|
||||||
|
"accettazione": "accettazione",
|
||||||
|
"non-accettazione": "non_accettazione",
|
||||||
|
"presa-in-carico": "presa_in_carico",
|
||||||
|
"avvenuta-consegna": "avvenuta_consegna",
|
||||||
|
"mancata-consegna": "mancata_consegna",
|
||||||
|
"errore-consegna": "errore_consegna",
|
||||||
|
"preavviso-mancata-consegna": "preavviso_mancata_consegna",
|
||||||
|
"rilevazione-virus": "rilevazione_virus",
|
||||||
|
"posta-certificata": "posta_certificata",
|
||||||
|
|
||||||
|
# ── Con underscore (variante Legalmail/Poste) ───────────────────────────
|
||||||
|
"non_accettazione": "non_accettazione",
|
||||||
|
"presa_in_carico": "presa_in_carico",
|
||||||
|
"avvenuta_consegna": "avvenuta_consegna",
|
||||||
|
"mancata_consegna": "mancata_consegna",
|
||||||
|
"errore_consegna": "errore_consegna",
|
||||||
|
"preavviso_mancata_consegna": "preavviso_mancata_consegna",
|
||||||
|
"rilevazione_virus": "rilevazione_virus",
|
||||||
|
"posta_certificata": "posta_certificata",
|
||||||
|
|
||||||
|
# ── Con spazio (variante Legalmail rara) ────────────────────────────────
|
||||||
|
"non accettazione": "non_accettazione",
|
||||||
|
"presa in carico": "presa_in_carico",
|
||||||
|
"avvenuta consegna": "avvenuta_consegna",
|
||||||
|
"mancata consegna": "mancata_consegna",
|
||||||
|
"errore consegna": "errore_consegna",
|
||||||
|
"preavviso mancata consegna": "preavviso_mancata_consegna",
|
||||||
|
"rilevazione virus": "rilevazione_virus",
|
||||||
|
|
||||||
|
# ── Abbreviazioni usate da alcuni provider ──────────────────────────────
|
||||||
|
"consegna": "avvenuta_consegna", # Legalmail abbreviazione
|
||||||
|
"no-consegna": "mancata_consegna",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PecClassification:
|
||||||
|
"""Risultato della classificazione di un messaggio PEC."""
|
||||||
|
|
||||||
|
pec_type: str # valore enum DB
|
||||||
|
riferimento_message_id: str | None # X-Riferimento-Message-ID
|
||||||
|
x_ricevuta: str | None # valore raw dell'header X-Ricevuta
|
||||||
|
x_tipo_ricevuta: str | None # valore raw dell'header X-TipoRicevuta
|
||||||
|
is_receipt: bool # True se è ricevuta (non messaggio originale)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_pec_message(msg: email.message.Message) -> PecClassification:
|
||||||
|
"""
|
||||||
|
Classifica il tipo di messaggio PEC analizzando gli header specifici.
|
||||||
|
|
||||||
|
Header letti (in ordine di priorità):
|
||||||
|
1. X-TipoRicevuta (Namirial, standard più recente)
|
||||||
|
2. X-Ricevuta (Aruba, Legalmail, InfoCert)
|
||||||
|
|
||||||
|
Se nessuno dei due è presente, il messaggio è classificato come
|
||||||
|
'posta_certificata' (messaggio PEC originale).
|
||||||
|
|
||||||
|
Per la correlazione con il messaggio originale (outbound):
|
||||||
|
- X-Riferimento-Message-ID contiene il Message-ID del messaggio inviato
|
||||||
|
"""
|
||||||
|
x_ricevuta = _clean_header(msg.get("X-Ricevuta"))
|
||||||
|
x_tipo = _clean_header(msg.get("X-TipoRicevuta"))
|
||||||
|
x_riferimento = _clean_header(msg.get("X-Riferimento-Message-ID"))
|
||||||
|
|
||||||
|
# X-TipoRicevuta ha precedenza
|
||||||
|
raw_type = (x_tipo or x_ricevuta or "").lower()
|
||||||
|
pec_type = _PEC_TYPE_MAP.get(raw_type, "posta_certificata")
|
||||||
|
is_receipt = pec_type != "posta_certificata"
|
||||||
|
|
||||||
|
return PecClassification(
|
||||||
|
pec_type=pec_type,
|
||||||
|
riferimento_message_id=x_riferimento,
|
||||||
|
x_ricevuta=x_ricevuta,
|
||||||
|
x_tipo_ricevuta=x_tipo,
|
||||||
|
is_receipt=is_receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_header(value: str | None) -> str | None:
|
||||||
|
"""Pulisce e normalizza il valore di un header PEC."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── State machine messaggi outbound ─────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Quando il worker riceve una ricevuta PEC (inbound), deve aggiornare
|
||||||
|
# lo stato del messaggio outbound corrispondente (trovato tramite
|
||||||
|
# X-Riferimento-Message-ID → message_id_header).
|
||||||
|
#
|
||||||
|
# Transizioni valide:
|
||||||
|
# (current_state, receipt_type) → new_state
|
||||||
|
#
|
||||||
|
# Stati terminali: "delivered", "anomaly", "failed"
|
||||||
|
# Non si deve retrocedere di stato (es. delivered → anomaly non è valido)
|
||||||
|
|
||||||
|
# Tipo ricevuta → nuovo stato da applicare al messaggio outbound
|
||||||
|
_RECEIPT_TO_STATE: dict[str, str] = {
|
||||||
|
"accettazione": "accepted",
|
||||||
|
"presa_in_carico": "accepted", # equivalente funzionale ad accepted
|
||||||
|
"avvenuta_consegna": "delivered",
|
||||||
|
"non_accettazione": "anomaly",
|
||||||
|
"mancata_consegna": "anomaly",
|
||||||
|
"errore_consegna": "anomaly",
|
||||||
|
"preavviso_mancata_consegna": "anomaly",
|
||||||
|
"rilevazione_virus": "anomaly",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Transizioni di stato valide per ciascuno stato corrente
|
||||||
|
# Solo le transizioni in avanti sono permesse (no retrocessioni)
|
||||||
|
VALID_OUTBOUND_TRANSITIONS: dict[str, set[str]] = {
|
||||||
|
"queued": {"accepted", "anomaly"},
|
||||||
|
"sent": {"accepted", "anomaly"},
|
||||||
|
"accepted": {"delivered", "anomaly"},
|
||||||
|
# delivered e anomaly sono terminali: nessuna transizione
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_state_transition(pec_type: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Restituisce il nuovo stato da applicare al messaggio outbound
|
||||||
|
quando arriva una ricevuta di questo tipo.
|
||||||
|
|
||||||
|
Ritorna None se il tipo non provoca transizioni di stato
|
||||||
|
(es. 'posta_certificata' non è una ricevuta).
|
||||||
|
"""
|
||||||
|
return _RECEIPT_TO_STATE.get(pec_type)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_outbound_transition(current_state: str, pec_type: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Applica la state machine al messaggio outbound.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_state: stato corrente del messaggio outbound
|
||||||
|
pec_type: tipo della ricevuta arrivata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Il nuovo stato se la transizione è valida, None altrimenti.
|
||||||
|
Ritorna None anche se lo stato è già terminale.
|
||||||
|
"""
|
||||||
|
new_state = get_state_transition(pec_type)
|
||||||
|
if new_state is None:
|
||||||
|
return None # Non è una ricevuta con effetto sullo stato
|
||||||
|
|
||||||
|
valid_targets = VALID_OUTBOUND_TRANSITIONS.get(current_state, set())
|
||||||
|
if new_state in valid_targets:
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Transizione stato ignorata: {current_state!r} → {new_state!r} "
|
||||||
|
f"(ricevuta: {pec_type!r})"
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Estrazione EML annidato nelle ricevute PEC (EML-in-EML) – Fase 3.
|
||||||
|
|
||||||
|
Una ricevuta PEC è strutturalmente un messaggio multipart che contiene:
|
||||||
|
- text/plain → testo leggibile della ricevuta
|
||||||
|
- application/xml → dati strutturati XML (daticert.xml, opzionale)
|
||||||
|
- message/rfc822 → il messaggio originale allegato (o la busta PEC)
|
||||||
|
|
||||||
|
Questo modulo estrae e analizza l'EML annidato per identificare
|
||||||
|
il messaggio originale a cui si riferisce la ricevuta.
|
||||||
|
|
||||||
|
Struttura tipica ricevuta Aruba PEC:
|
||||||
|
multipart/mixed
|
||||||
|
├── text/plain (descrizione ricevuta)
|
||||||
|
├── application/xml (daticert.xml – campi strutturati)
|
||||||
|
└── message/rfc822 (messaggio originale)
|
||||||
|
├── From: mittente@pec.it
|
||||||
|
├── To: destinatario@pec.it
|
||||||
|
└── Subject: ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import email
|
||||||
|
import email.header
|
||||||
|
import email.message
|
||||||
|
import email.utils
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NestedEmlInfo:
|
||||||
|
"""Informazioni estratte dall'EML annidato in una ricevuta PEC."""
|
||||||
|
|
||||||
|
raw_bytes: bytes
|
||||||
|
message_id: str | None
|
||||||
|
subject: str | None
|
||||||
|
from_address: str | None
|
||||||
|
to_addresses: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_nested_eml(msg: email.message.Message) -> NestedEmlInfo | None:
|
||||||
|
"""
|
||||||
|
Estrae l'EML annidato (message/rfc822) da una ricevuta PEC.
|
||||||
|
|
||||||
|
Naviga il messaggio alla ricerca della prima parte di tipo message/rfc822
|
||||||
|
e restituisce le informazioni estratte dall'EML interno.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NestedEmlInfo se trovato un EML annidato, None altrimenti.
|
||||||
|
"""
|
||||||
|
if not msg.is_multipart():
|
||||||
|
return None
|
||||||
|
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.get_content_type() != "message/rfc822":
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = part.get_payload()
|
||||||
|
|
||||||
|
inner_bytes: bytes | None = None
|
||||||
|
inner_msg: email.message.Message | None = None
|
||||||
|
|
||||||
|
if isinstance(payload, list) and payload:
|
||||||
|
# Forma canonica RFC: payload è lista di Message
|
||||||
|
candidate = payload[0]
|
||||||
|
if isinstance(candidate, email.message.Message):
|
||||||
|
inner_msg = candidate
|
||||||
|
inner_bytes = candidate.as_bytes()
|
||||||
|
|
||||||
|
elif isinstance(payload, bytes):
|
||||||
|
inner_bytes = payload
|
||||||
|
try:
|
||||||
|
inner_msg = email.message_from_bytes(payload)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif isinstance(payload, str):
|
||||||
|
inner_bytes = payload.encode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
inner_msg = email.message_from_bytes(inner_bytes)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if inner_bytes and inner_msg:
|
||||||
|
return _extract_info_from_inner(inner_msg, inner_bytes)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Errore estrazione EML annidato: {exc}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_receipt_xml(msg: email.message.Message) -> str | None:
|
||||||
|
"""
|
||||||
|
Estrae il contenuto XML strutturato da una ricevuta PEC.
|
||||||
|
|
||||||
|
Le ricevute PEC conformi al DM 2 novembre 2005 contengono un allegato XML
|
||||||
|
(tipicamente "daticert.xml") con i campi strutturati della ricevuta:
|
||||||
|
tipo, timestamp, identificatore messaggio, mittente, destinatario, ecc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stringa XML se trovata, None altrimenti.
|
||||||
|
"""
|
||||||
|
for part in msg.walk():
|
||||||
|
ct = part.get_content_type()
|
||||||
|
if ct not in ("application/xml", "text/xml"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Verifica che sia il daticert (opzionale: controlla filename)
|
||||||
|
filename = part.get_filename() or part.get_param("name") or ""
|
||||||
|
# Accetta qualsiasi XML nelle ricevute PEC (non solo daticert.xml)
|
||||||
|
try:
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
return payload.decode(charset, errors="replace")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"Errore estrazione XML ricevuta (filename={filename!r}): {exc}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helper privati ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_info_from_inner(
|
||||||
|
msg: email.message.Message,
|
||||||
|
raw_bytes: bytes,
|
||||||
|
) -> NestedEmlInfo:
|
||||||
|
"""Estrae i campi identificativi dall'EML annidato."""
|
||||||
|
|
||||||
|
message_id = msg.get("Message-ID", "").strip() or None
|
||||||
|
subject = _decode_hdr(msg.get("Subject"))
|
||||||
|
from_address = email.utils.parseaddr(msg.get("From", ""))[1] or None
|
||||||
|
to_addresses = _extract_addrs(msg.get("To"))
|
||||||
|
|
||||||
|
return NestedEmlInfo(
|
||||||
|
raw_bytes=raw_bytes,
|
||||||
|
message_id=message_id,
|
||||||
|
subject=subject,
|
||||||
|
from_address=from_address,
|
||||||
|
to_addresses=to_addresses,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_hdr(value: str | None) -> str | None:
|
||||||
|
"""Decodifica un header RFC 2047."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parts = email.header.decode_header(value)
|
||||||
|
decoded = []
|
||||||
|
for part, charset in parts:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
decoded.append(str(part))
|
||||||
|
return "".join(decoded).strip()
|
||||||
|
except Exception:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_addrs(field_value: str | None) -> list[str]:
|
||||||
|
"""Estrae lista di indirizzi email da un campo header."""
|
||||||
|
if not field_value:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = email.utils.getaddresses([field_value])
|
||||||
|
return [addr for _, addr in parsed if addr]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>"
|
||||||
Reference in New Issue
Block a user