This commit is contained in:
2026-03-18 17:43:03 +01:00
parent d80d912fb3
commit c89c08c397
10 changed files with 2352 additions and 123 deletions
+193 -122
View File
@@ -1,20 +1,25 @@
"""
Logica di sincronizzazione messaggi IMAP.
Logica di sincronizzazione messaggi IMAP Fase 3 aggiornata.
Responsabilità:
1. Fetch della lista UID > last_sync_uid
2. Download envelope + raw EML per ogni UID
3. Parsing base degli header (subject, from, to, date)
4. Salvataggio in tabella messages
3. Parsing completo EML tramite app.parsers (Fase 3):
- Classificazione tipo PEC (X-Ricevuta / X-TipoRicevuta)
- Estrazione allegati (body text/html + allegati file)
- EML-in-EML per ricevute PEC
4. Salvataggio messaggio in tabella messages
5. Upload raw EML su MinIO
6. Aggiornamento last_sync_uid e last_sync_at sulla mailbox
7. Pubblicazione evento Redis per notifica WebSocket
6. Upload allegati su MinIO + inserimento in tabella attachments
7. State machine messaggi outbound (sent→accepted→delivered/anomaly)
tramite X-Riferimento-Message-ID
8. Aggiornamento last_sync_uid e last_sync_at sulla mailbox
9. Pubblicazione evento Redis per notifica WebSocket
"""
import email
import email.header
import email.utils
import hashlib
import json
import logging
import re
@@ -27,14 +32,16 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import Mailbox, Message
from app.storage.minio_client import upload_eml
from app.models import Attachment, Mailbox, Message
from app.parsers.eml_parser import parse_eml
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message
from app.storage.minio_client import upload_attachment, upload_eml
logger = logging.getLogger(__name__)
settings = get_settings()
# ─── Helper: decodifica header email ─────────────────────────────────────────
# ─── Helper legacy (mantenuti per backward compatibility con i test) ──────────
def _decode_header(header_value: str | None) -> str | None:
"""Decodifica header RFC 2047 (es. =?utf-8?b?...?=) in stringa Python."""
@@ -80,93 +87,38 @@ def _parse_date(date_str: str | None) -> datetime | None:
def _classify_pec_type(msg: email.message.Message) -> str:
"""
Classifica il tipo PEC dal header X-Ricevuta / X-TipoRicevuta.
Fase 3 fa il parsing completo; qui classifichiamo al meglio possibile.
Mantenuto per backward compatibility usa il parser completo internamente.
"""
x_ricevuta = msg.get("X-Ricevuta", "").lower()
x_tipo = msg.get("X-TipoRicevuta", "").lower()
TYPE_MAP = {
"accettazione": "accettazione",
"non-accettazione": "non_accettazione",
"presa-in-carico": "presa_in_carico",
"avvenuta-consegna": "avvenuta_consegna",
"mancata-consegna": "mancata_consegna",
"errore-consegna": "errore_consegna",
"preavviso-mancata-consegna": "preavviso_mancata_consegna",
"rilevazione-virus": "rilevazione_virus",
}
value = x_tipo or x_ricevuta
return TYPE_MAP.get(value, "posta_certificata")
pec_class = classify_pec_message(msg)
return pec_class.pec_type
def _parse_eml(raw_bytes: bytes) -> dict:
"""
Parsing di base di un EML estrae i campi necessari per la tabella messages.
Il parsing completo (body, allegati, EML-in-EML) è in Fase 3.
Parsing di base di un EML.
Wrapper del nuovo parser completo (Fase 3).
Mantenuto per backward compatibility con i test esistenti.
Restituisce un dict con i campi base necessari per la tabella messages.
"""
try:
msg = email.message_from_bytes(raw_bytes)
except Exception as e:
logger.warning(f"Errore parsing EML: {e}")
return {}
parsed = parse_eml(raw_bytes)
subject = _decode_header(msg.get("Subject"))
from_addr = email.utils.parseaddr(msg.get("From", ""))[1] or None
to_addrs = _extract_addresses(msg.get("To"))
cc_addrs = _extract_addresses(msg.get("Cc"))
message_id = msg.get("Message-ID", "").strip() or None
date = _parse_date(msg.get("Date"))
pec_type = _classify_pec_type(msg)
# Estrazione body text/html (best-effort Fase 3 fa il parsing completo)
body_text = None
body_html = None
has_attachments = False
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
disp = part.get("Content-Disposition", "")
if "attachment" in disp or "inline" in disp:
if part.get_filename():
has_attachments = True
elif ct == "text/plain" and body_text is None:
try:
charset = part.get_content_charset() or "utf-8"
body_text = part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
elif ct == "text/html" and body_html is None:
try:
charset = part.get_content_charset() or "utf-8"
body_html = part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
else:
ct = msg.get_content_type()
try:
charset = msg.get_content_charset() or "utf-8"
payload = msg.get_payload(decode=True)
if payload:
if ct == "text/plain":
body_text = payload.decode(charset, errors="replace")
elif ct == "text/html":
body_html = payload.decode(charset, errors="replace")
except Exception:
pass
pec_type = "posta_certificata"
if parsed.raw_message is not None:
pec_class = classify_pec_message(parsed.raw_message)
pec_type = pec_class.pec_type
return {
"subject": subject,
"from_address": from_addr,
"to_addresses": to_addrs if to_addrs else None,
"cc_addresses": cc_addrs if cc_addrs else None,
"message_id_header": message_id,
"sent_at": date,
"subject": parsed.subject,
"from_address": parsed.from_address,
"to_addresses": parsed.to_addresses if parsed.to_addresses else None,
"cc_addresses": parsed.cc_addresses if parsed.cc_addresses else None,
"message_id_header": parsed.message_id,
"sent_at": parsed.date,
"pec_type": pec_type,
"body_text": body_text,
"body_html": body_html,
"has_attachments": has_attachments,
"body_text": parsed.body_text,
"body_html": parsed.body_html,
"has_attachments": parsed.has_attachments,
}
@@ -188,8 +140,6 @@ async def sync_new_messages(
search_range = f"{last_uid + 1}:*"
# ── SEARCH UID > last_sync_uid ─────────────────────────────────────────────
# aioimaplib non supporta uid('SEARCH',...) → usare search('UID', range)
# che invia "SEARCH UID n:*" e restituisce numeri di sequenza
try:
status, search_data = await imap_client.search("UID", search_range)
except Exception as e:
@@ -202,7 +152,6 @@ async def sync_new_messages(
)
return 0
# search() restituisce numeri di sequenza (non UID)
raw_seqs = b" ".join(
d if isinstance(d, bytes) else d.encode() for d in search_data
).decode("ascii", errors="ignore").split()
@@ -211,7 +160,6 @@ async def sync_new_messages(
if not seq_numbers:
return 0
# Limita il numero di fetch per ciclo
seq_numbers = seq_numbers[: settings.imap_max_fetch_per_cycle]
logger.info(
f"[{mailbox.email_address}] Trovati {len(seq_numbers)} messaggi nuovi da sincronizzare"
@@ -259,12 +207,10 @@ async def _fetch_and_save_message_by_seq(
) -> tuple[int | None, bool]:
"""
Fetcha un singolo messaggio per NUMERO DI SEQUENZA (non UID).
Include UID nella richiesta FETCH per estrarlo dalla risposta.
Returns:
(uid, saved): UID del messaggio e True se salvato, False altrimenti.
"""
# FETCH seq (UID RFC822 RFC822.SIZE)
try:
status, fetch_data = await imap_client.fetch(seq, "(UID RFC822 RFC822.SIZE)")
except Exception as e:
@@ -277,27 +223,18 @@ async def _fetch_and_save_message_by_seq(
)
return None, False
# Debug: mostra la struttura di fetch_data
items_info = [(type(x).__name__, len(x) if isinstance(x, (bytes, str)) else str(x)) for x in fetch_data]
logger.debug(f"[{mailbox.email_address}] fetch_data seq {seq}: {items_info}")
# Estrae UID, raw EML e size dalla risposta.
# NOTA CRITICA: aioimaplib restituisce il corpo EML come `bytearray` (non `bytes`)!
# [0] bytes → FETCH response header con UID e RFC822.SIZE
# [1] bytearray → raw EML (il corpo del messaggio)
# [2] bytes → ')' (chiusura)
# [3] bytes → riga OK finale
uid: int | None = None
raw_eml: bytes | None = None
size_bytes: int | None = None
for item in fetch_data:
if isinstance(item, bytearray):
# Questo è il corpo del messaggio EML
if len(item) > 200:
raw_eml = bytes(item)
elif isinstance(item, bytes):
# Risposta header estrae UID e RFC822.SIZE
item_str = item.decode("ascii", errors="ignore")
uid_match = re.search(r"UID\s+(\d+)", item_str)
if uid_match:
@@ -314,7 +251,6 @@ async def _fetch_and_save_message_by_seq(
size_bytes = int(size_match.group(1))
if uid is None or uid <= last_uid:
# Questo messaggio ha un UID <= last_uid, non va sincronizzato
return uid, False
if not raw_eml:
@@ -343,7 +279,6 @@ async def _fetch_and_save_message(
) -> bool:
"""
Fetcha un singolo messaggio per UID (usato dal job sync_mailbox one-shot).
Usa UID FETCH (aioimaplib uid() method).
"""
existing = await db.execute(
select(Message.id).where(
@@ -387,6 +322,8 @@ async def _fetch_and_save_message(
)
# ─── Save message (Fase 3 con parser completo, allegati, state machine) ────
async def _save_message(
uid: int,
raw_eml: bytes,
@@ -396,9 +333,16 @@ async def _save_message(
redis_client: aioredis.Redis,
) -> bool:
"""
Salva un messaggio EML in DB e su MinIO. Pubblica evento WebSocket.
Salva un messaggio EML in DB e su MinIO.
Fase 3 aggiornato per:
- Parser completo (body, allegati, EML-in-EML)
- Classificazione precisa tipo PEC (tutti i provider)
- Salvataggio allegati su MinIO + tabella attachments
- State machine outbound: aggiorna stato messaggio originale alla ricezione ricevuta
- Collegamento parent_message_id via X-Riferimento-Message-ID
"""
# Idempotenza
# ── Idempotenza ───────────────────────────────────────────────────────────
existing = await db.execute(
select(Message.id).where(
Message.mailbox_id == mailbox.id,
@@ -409,10 +353,25 @@ async def _save_message(
logger.debug(f"[{mailbox.email_address}] UID {uid} già in DB, skip")
return False
parsed = _parse_eml(raw_eml)
# ── Parsing completo EML ──────────────────────────────────────────────────
parsed = parse_eml(raw_eml)
pec_class = classify_pec_message(
parsed.raw_message or email.message_from_bytes(raw_eml)
)
received_at = datetime.now(UTC)
# Upload su MinIO
# ── State machine: trova e aggiorna messaggio outbound ────────────────────
parent_message_id: uuid.UUID | None = None
if pec_class.is_receipt and pec_class.riferimento_message_id:
parent_message_id = await _apply_outbound_state_machine(
riferimento_message_id=pec_class.riferimento_message_id,
pec_type=pec_class.pec_type,
tenant_id=mailbox.tenant_id,
db=db,
)
# ── Upload raw EML su MinIO ───────────────────────────────────────────────
eml_path: str | None = None
try:
eml_path = await upload_eml(
@@ -422,9 +381,9 @@ async def _save_message(
eml_bytes=raw_eml,
)
except Exception as e:
logger.error(f"[{mailbox.email_address}] Upload MinIO UID {uid}: {e}")
logger.error(f"[{mailbox.email_address}] Upload EML MinIO UID {uid}: {e}")
# Salva in DB
# ── Salva messaggio in DB ─────────────────────────────────────────────────
message = Message(
id=uuid.uuid4(),
tenant_id=mailbox.tenant_id,
@@ -433,25 +392,35 @@ async def _save_message(
imap_folder="INBOX",
direction="inbound",
state="received",
pec_type=parsed.get("pec_type", "posta_certificata"),
subject=parsed.get("subject"),
from_address=parsed.get("from_address"),
to_addresses=parsed.get("to_addresses"),
cc_addresses=parsed.get("cc_addresses"),
message_id_header=parsed.get("message_id_header"),
sent_at=parsed.get("sent_at"),
pec_type=pec_class.pec_type,
subject=parsed.subject,
from_address=parsed.from_address,
to_addresses=parsed.to_addresses if parsed.to_addresses else None,
cc_addresses=parsed.cc_addresses if parsed.cc_addresses else None,
message_id_header=parsed.message_id,
sent_at=parsed.date,
received_at=received_at,
size_bytes=size_bytes,
body_text=parsed.get("body_text"),
body_html=parsed.get("body_html"),
has_attachments=parsed.get("has_attachments", False),
body_text=parsed.body_text,
body_html=parsed.body_html,
has_attachments=parsed.has_attachments,
parent_message_id=parent_message_id,
raw_eml_path=eml_path,
is_read=False,
)
db.add(message)
await db.flush()
await db.flush() # ottieni message.id prima di salvare gli allegati
# Pubblica evento Redis per WebSocket
# ── Salva allegati su MinIO + tabella attachments ─────────────────────────
if parsed.attachments:
await _save_attachments(
attachments=parsed.attachments,
message=message,
mailbox=mailbox,
db=db,
)
# ── Pubblica evento Redis per WebSocket ───────────────────────────────────
try:
event = {
"type": "mailbox:new_message",
@@ -460,6 +429,7 @@ async def _save_message(
"subject": message.subject or "",
"from_address": message.from_address or "",
"pec_type": message.pec_type,
"is_receipt": pec_class.is_receipt,
"received_at": received_at.isoformat(),
}
await redis_client.publish(f"ws:tenant:{mailbox.tenant_id}", json.dumps(event))
@@ -468,6 +438,107 @@ async def _save_message(
logger.info(
f"[{mailbox.email_address}] Nuovo messaggio: UID={uid} "
f"subject={message.subject!r} pec_type={message.pec_type}"
f"pec_type={pec_class.pec_type!r} "
f"subject={message.subject!r} "
f"allegati={len(parsed.attachments)}"
)
return True
async def _apply_outbound_state_machine(
riferimento_message_id: str,
pec_type: str,
tenant_id: uuid.UUID,
db: AsyncSession,
) -> uuid.UUID | None:
"""
Aggiorna lo stato del messaggio outbound originale in base alla ricevuta.
Cerca il messaggio outbound con message_id_header == riferimento_message_id,
applica la transizione di stato se valida.
Returns:
UUID del messaggio originale se trovato, None altrimenti.
"""
result = await db.execute(
select(Message).where(
Message.tenant_id == tenant_id,
Message.message_id_header == riferimento_message_id,
Message.direction == "outbound",
)
)
parent_msg = result.scalar_one_or_none()
if not parent_msg:
logger.debug(
f"Messaggio outbound non trovato per riferimento={riferimento_message_id!r} "
f"(potrebbe essere stato inviato da client diverso)"
)
return None
new_state = apply_outbound_transition(parent_msg.state, pec_type)
if new_state:
old_state = parent_msg.state
parent_msg.state = new_state
parent_msg.updated_at = datetime.now(UTC)
await db.flush()
logger.info(
f"State machine outbound: {riferimento_message_id!r} "
f"{old_state!r}{new_state!r} (ricevuta: {pec_type!r})"
)
return parent_msg.id
async def _save_attachments(
attachments: list,
message: Message,
mailbox: Mailbox,
db: AsyncSession,
) -> None:
"""
Carica gli allegati su MinIO e inserisce i record in tabella attachments.
Args:
attachments: lista di AttachmentInfo dal parser EML
message: messaggio DB a cui appartengono gli allegati
mailbox: casella (per il path MinIO)
db: sessione DB
"""
for att in attachments:
storage_path: str | None = None
try:
storage_path = await upload_attachment(
tenant_id=str(mailbox.tenant_id),
mailbox_id=str(mailbox.id),
message_id=str(message.id),
filename=att.filename,
content=att.content,
content_type=att.content_type,
)
except Exception as e:
logger.error(
f"Upload allegato {att.filename!r} per messaggio {message.id}: {e}"
)
# Continua con gli altri allegati anche se uno fallisce
continue
# Inserisci record in DB
att_record = Attachment(
id=uuid.uuid4(),
tenant_id=message.tenant_id,
message_id=message.id,
filename=att.filename,
content_type=att.content_type,
size_bytes=att.size_bytes,
storage_path=storage_path,
checksum_sha256=att.checksum_sha256,
)
db.add(att_record)
# flush per persistere tutti gli allegati nella transazione corrente
try:
await db.flush()
except Exception as e:
logger.error(f"Errore flush allegati per messaggio {message.id}: {e}")
+36 -1
View File
@@ -23,7 +23,7 @@ from sqlalchemy import (
Integer, String, Text, func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
@@ -126,3 +126,38 @@ class Message(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
# Relazione con allegati (usata dal worker per inserimento)
attachments: Mapped[list["Attachment"]] = relationship(
"Attachment", back_populates="message", cascade="all, delete-orphan"
)
class Attachment(Base):
"""
Allegato di un messaggio PEC.
Corrisponde alla tabella `attachments` nel DB.
"""
__tablename__ = "attachments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="CASCADE"),
nullable=False,
)
filename: Mapped[str] = mapped_column(String(512), nullable=False)
content_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# Relazione inversa verso Message
message: Mapped["Message"] = relationship("Message", back_populates="attachments")
+34
View File
@@ -0,0 +1,34 @@
"""
Parsers PEC Fase 3.
Esporta i moduli principali per il parsing di messaggi PEC:
- eml_parser: parsing MIME completo (body, allegati, header)
- pec_parser: classificazione tipo PEC da header X-Ricevuta / X-TipoRicevuta
- receipt_extractor: estrazione EML annidato nelle ricevute (EML-in-EML)
"""
from app.parsers.eml_parser import AttachmentInfo, ParsedEmail, parse_eml
from app.parsers.pec_parser import (
PecClassification,
VALID_OUTBOUND_TRANSITIONS,
classify_pec_message,
get_state_transition,
)
from app.parsers.receipt_extractor import (
NestedEmlInfo,
extract_nested_eml,
extract_receipt_xml,
)
__all__ = [
"AttachmentInfo",
"ParsedEmail",
"parse_eml",
"PecClassification",
"VALID_OUTBOUND_TRANSITIONS",
"classify_pec_message",
"get_state_transition",
"NestedEmlInfo",
"extract_nested_eml",
"extract_receipt_xml",
]
+361
View File
@@ -0,0 +1,361 @@
"""
Parser MIME completo per messaggi email/PEC (Fase 3).
Responsabilità:
- Parsing MIME ricorsivo (multipart, nested)
- Estrazione body text e html
- Estrazione allegati con filename, content_type, sha256
- Decodifica header RFC 2047 (=?utf-8?b?...?=)
- Gestione parti message/rfc822 (EML-in-EML per ricevute PEC)
"""
import email
import email.header
import email.message
import email.utils
import hashlib
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
logger = logging.getLogger(__name__)
# ─── Data classes ─────────────────────────────────────────────────────────────
@dataclass
class AttachmentInfo:
"""Dati estratti da un singolo allegato MIME."""
filename: str
content_type: str
content: bytes
size_bytes: int
checksum_sha256: str
is_inline: bool = False # True per parti inline (immagini nel corpo HTML)
is_pec_system: bool = False # True per file di infrastruttura PEC (daticert.xml, postacert.eml)
@dataclass
class ParsedEmail:
"""Risultato completo del parsing di un EML."""
subject: str | None = None
from_address: str | None = None
to_addresses: list[str] = field(default_factory=list)
cc_addresses: list[str] = field(default_factory=list)
message_id: str | None = None
date: datetime | None = None
body_text: str | None = None
body_html: str | None = None
attachments: list[AttachmentInfo] = field(default_factory=list)
has_attachments: bool = False
# Riferimento all'oggetto email.message.Message originale (per parser PEC)
raw_message: email.message.Message | None = None
# ─── Funzioni helper (pubbliche, usate anche dai test) ───────────────────────
def decode_header(value: str | None) -> str | None:
"""
Decodifica un header RFC 2047 (=?charset?encoding?text?=) in stringa Python.
Esempi:
=?utf-8?b?UEVDIHRlc3Q=?= → "PEC test"
=?iso-8859-1?q?Multa_n=2E_123?= → "Multa n. 123"
"""
if not value:
return None
try:
parts = email.header.decode_header(value)
decoded = []
for part, charset in parts:
if isinstance(part, bytes):
decoded.append(part.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(str(part))
return "".join(decoded).strip()
except Exception:
return str(value)
def extract_addresses(field_value: str | None) -> list[str]:
"""
Estrae una lista di indirizzi email da un campo header To/Cc/From.
Gestisce sia "addr@example.com" sia "Nome <addr@example.com>".
"""
if not field_value:
return []
try:
parsed = email.utils.getaddresses([field_value])
return [addr for _, addr in parsed if addr]
except Exception:
return []
def parse_date(date_str: str | None) -> datetime | None:
"""
Converte una stringa data RFC 2822 (header Date) in datetime con timezone.
Ritorna None in caso di stringa non valida.
"""
if not date_str:
return None
try:
parsed = email.utils.parsedate_to_datetime(date_str)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
except Exception:
return None
# ─── Parser principale ────────────────────────────────────────────────────────
def parse_eml(raw_bytes: bytes) -> ParsedEmail:
"""
Parsing completo di un raw EML.
Estrae:
- Header: Subject, From, To, Cc, Message-ID, Date
- Body: text/plain, text/html (primo trovato per tipo)
- Allegati: tutti i parti con filename, inclusi message/rfc822
Args:
raw_bytes: byte del messaggio EML grezzo
Returns:
ParsedEmail con tutti i campi estratti (fields None/[] se non presenti)
"""
if not raw_bytes:
return ParsedEmail()
try:
msg = email.message_from_bytes(raw_bytes)
except Exception as exc:
logger.warning(f"Errore parsing EML bytes: {exc}")
return ParsedEmail()
result = ParsedEmail()
result.raw_message = msg
# ── Header ────────────────────────────────────────────────────────────────
result.subject = decode_header(msg.get("Subject"))
result.from_address = email.utils.parseaddr(msg.get("From", ""))[1] or None
result.to_addresses = extract_addresses(msg.get("To"))
result.cc_addresses = extract_addresses(msg.get("Cc"))
result.message_id = msg.get("Message-ID", "").strip() or None
result.date = parse_date(msg.get("Date"))
# ── Body e allegati ───────────────────────────────────────────────────────
if msg.is_multipart():
_walk_parts(msg, result)
else:
_extract_single_part_body(msg, result)
result.has_attachments = any(
not att.is_pec_system for att in result.attachments
) if result.attachments else False
return result
# ─── Helper privati ───────────────────────────────────────────────────────────
# Nomi file usati dall'infrastruttura PEC (non allegati utente)
_PEC_SYSTEM_FILENAMES = frozenset({
"daticert.xml",
"postacert.eml",
"smime.p7s",
"smime.p7m",
})
# Content-type usati dall'infrastruttura PEC
_PEC_SYSTEM_CONTENT_TYPES = frozenset({
"application/pkcs7-signature",
"application/pkcs7-mime",
})
def _is_pec_system_part(filename: str | None, content_type: str) -> bool:
"""Determina se un allegato è parte dell'infrastruttura PEC."""
if filename and filename.lower() in _PEC_SYSTEM_FILENAMES:
return True
if content_type in _PEC_SYSTEM_CONTENT_TYPES:
return True
return False
def _get_filename(part: email.message.Message) -> str | None:
"""
Estrae il filename da un part MIME.
Prova nell'ordine:
1. Content-Disposition: attachment; filename="..."
2. Content-Type: ...; name="..."
"""
filename = part.get_filename()
if filename:
return decode_header(filename)
name = part.get_param("name")
if name:
return decode_header(name)
return None
def _walk_parts(msg: email.message.Message, result: ParsedEmail) -> None:
"""
Naviga ricorsivamente tutti i part MIME del messaggio.
Logica:
- multipart/* → salta (è un container, i figli vengono visitati nel loop)
- text/plain → body_text (primo trovato, se non è "attachment")
- text/html → body_html (primo trovato, se non è "attachment")
- message/rfc822 → allegato di tipo EML-in-EML
- tutto il resto con filename → allegato
"""
for part in msg.walk():
# Salta container multipart
if part.get_content_maintype() == "multipart":
continue
ct = part.get_content_type()
disp = part.get("Content-Disposition", "")
filename = _get_filename(part)
# ── EML-in-EML (message/rfc822) ───────────────────────────────────────
if ct == "message/rfc822":
_extract_eml_in_eml(part, filename, result)
continue
# ── Allegato esplicito (Content-Disposition: attachment) ──────────────
if "attachment" in disp:
if filename:
_extract_attachment(part, filename, ct, is_inline=False, result=result)
continue
# ── Parte inline con filename (es. immagine embeds in HTML) ───────────
if "inline" in disp and filename:
_extract_attachment(part, filename, ct, is_inline=True, result=result)
continue
# ── Body text/plain ───────────────────────────────────────────────────
if ct == "text/plain" and result.body_text is None:
body = _decode_payload(part)
if body is not None:
result.body_text = body
continue
# ── Body text/html ────────────────────────────────────────────────────
if ct == "text/html" and result.body_html is None:
body = _decode_payload(part)
if body is not None:
result.body_html = body
continue
# ── Altri tipi con filename → allegato ────────────────────────────────
if filename:
_extract_attachment(part, filename, ct, is_inline=False, result=result)
def _extract_single_part_body(msg: email.message.Message, result: ParsedEmail) -> None:
"""Estrae il body per messaggi non-multipart."""
ct = msg.get_content_type()
body = _decode_payload(msg)
if body is None:
return
if ct == "text/plain":
result.body_text = body
elif ct == "text/html":
result.body_html = body
def _decode_payload(part: email.message.Message) -> str | None:
"""Decodifica il payload di una parte MIME come stringa testo."""
try:
raw = part.get_payload(decode=True)
if raw is None:
return None
charset = part.get_content_charset() or "utf-8"
return raw.decode(charset, errors="replace")
except Exception as exc:
logger.debug(f"Errore decodifica payload {part.get_content_type()}: {exc}")
return None
def _extract_eml_in_eml(
part: email.message.Message,
filename: str | None,
result: ParsedEmail,
) -> None:
"""
Estrae il messaggio EML annidato in un part message/rfc822.
Nella struttura PEC, questo è tipicamente il messaggio originale
allegato alle ricevute di consegna/accettazione.
"""
try:
payload = part.get_payload()
inner_bytes: bytes | None = None
if isinstance(payload, list) and payload:
# Forma canonica: payload è lista di Message
inner_msg = payload[0]
if isinstance(inner_msg, email.message.Message):
inner_bytes = inner_msg.as_bytes()
elif isinstance(payload, bytes):
inner_bytes = payload
elif isinstance(payload, str):
inner_bytes = payload.encode("utf-8", errors="replace")
if inner_bytes:
eff_filename = filename or "inner_message.eml"
is_system = _is_pec_system_part(eff_filename, "message/rfc822")
att = AttachmentInfo(
filename=eff_filename,
content_type="message/rfc822",
content=inner_bytes,
size_bytes=len(inner_bytes),
checksum_sha256=hashlib.sha256(inner_bytes).hexdigest(),
is_inline=False,
is_pec_system=is_system,
)
result.attachments.append(att)
except Exception as exc:
logger.warning(f"Errore estrazione EML-in-EML: {exc}")
def _extract_attachment(
part: email.message.Message,
filename: str,
content_type: str,
is_inline: bool,
result: ParsedEmail,
) -> None:
"""Estrae il contenuto di un allegato e lo aggiunge al ParsedEmail."""
try:
content = part.get_payload(decode=True)
if content is None:
return
is_system = _is_pec_system_part(filename, content_type)
att = AttachmentInfo(
filename=filename,
content_type=content_type,
content=content,
size_bytes=len(content),
checksum_sha256=hashlib.sha256(content).hexdigest(),
is_inline=is_inline,
is_pec_system=is_system,
)
result.attachments.append(att)
except Exception as exc:
logger.warning(f"Errore estrazione allegato {filename!r}: {exc}")
+192
View File
@@ -0,0 +1,192 @@
"""
Parser specifico per header PEC (Fase 3).
Classifica il tipo di messaggio PEC leggendo gli header:
- X-Ricevuta → tipo ricevuta (Aruba, Legalmail)
- X-TipoRicevuta → tipo ricevuta alternativo (Namirial)
- X-Riferimento-Message-ID → Message-ID del messaggio originale
- X-Trasporto → indicatore trasporto PEC (presenza indica PEC)
Il risultato della classificazione determina:
- `pec_type`: valore enum DB (posta_certificata, accettazione, ...)
- `is_receipt`: True se il messaggio è una ricevuta (non l'originale)
- `riferimento_message_id`: usato per collegare parent_message_id
State machine messaggi outbound:
sent/queued → accepted (ricevuta di accettazione o presa in carico)
accepted → delivered (ricevuta di avvenuta consegna)
any valid → anomaly (non-accettazione, mancata consegna, errore, virus)
"""
import email.message
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# ─── Mapping header PEC → enum DB ────────────────────────────────────────────
#
# I diversi provider PEC usano varianti nei valori degli header:
# Aruba PEC: X-Ricevuta con trattini (es. "avvenuta-consegna")
# Namirial: X-TipoRicevuta (stessa sintassi di Aruba)
# Legalmail: X-Ricevuta, talvolta con underscore o spazi
# InfoCert: X-Ricevuta, stessa sintassi Aruba
_PEC_TYPE_MAP: dict[str, str] = {
# ── Standard con trattini (Aruba, Namirial, InfoCert) ──────────────────
"accettazione": "accettazione",
"non-accettazione": "non_accettazione",
"presa-in-carico": "presa_in_carico",
"avvenuta-consegna": "avvenuta_consegna",
"mancata-consegna": "mancata_consegna",
"errore-consegna": "errore_consegna",
"preavviso-mancata-consegna": "preavviso_mancata_consegna",
"rilevazione-virus": "rilevazione_virus",
"posta-certificata": "posta_certificata",
# ── Con underscore (variante Legalmail/Poste) ───────────────────────────
"non_accettazione": "non_accettazione",
"presa_in_carico": "presa_in_carico",
"avvenuta_consegna": "avvenuta_consegna",
"mancata_consegna": "mancata_consegna",
"errore_consegna": "errore_consegna",
"preavviso_mancata_consegna": "preavviso_mancata_consegna",
"rilevazione_virus": "rilevazione_virus",
"posta_certificata": "posta_certificata",
# ── Con spazio (variante Legalmail rara) ────────────────────────────────
"non accettazione": "non_accettazione",
"presa in carico": "presa_in_carico",
"avvenuta consegna": "avvenuta_consegna",
"mancata consegna": "mancata_consegna",
"errore consegna": "errore_consegna",
"preavviso mancata consegna": "preavviso_mancata_consegna",
"rilevazione virus": "rilevazione_virus",
# ── Abbreviazioni usate da alcuni provider ──────────────────────────────
"consegna": "avvenuta_consegna", # Legalmail abbreviazione
"no-consegna": "mancata_consegna",
}
@dataclass
class PecClassification:
"""Risultato della classificazione di un messaggio PEC."""
pec_type: str # valore enum DB
riferimento_message_id: str | None # X-Riferimento-Message-ID
x_ricevuta: str | None # valore raw dell'header X-Ricevuta
x_tipo_ricevuta: str | None # valore raw dell'header X-TipoRicevuta
is_receipt: bool # True se è ricevuta (non messaggio originale)
def classify_pec_message(msg: email.message.Message) -> PecClassification:
"""
Classifica il tipo di messaggio PEC analizzando gli header specifici.
Header letti (in ordine di priorità):
1. X-TipoRicevuta (Namirial, standard più recente)
2. X-Ricevuta (Aruba, Legalmail, InfoCert)
Se nessuno dei due è presente, il messaggio è classificato come
'posta_certificata' (messaggio PEC originale).
Per la correlazione con il messaggio originale (outbound):
- X-Riferimento-Message-ID contiene il Message-ID del messaggio inviato
"""
x_ricevuta = _clean_header(msg.get("X-Ricevuta"))
x_tipo = _clean_header(msg.get("X-TipoRicevuta"))
x_riferimento = _clean_header(msg.get("X-Riferimento-Message-ID"))
# X-TipoRicevuta ha precedenza
raw_type = (x_tipo or x_ricevuta or "").lower()
pec_type = _PEC_TYPE_MAP.get(raw_type, "posta_certificata")
is_receipt = pec_type != "posta_certificata"
return PecClassification(
pec_type=pec_type,
riferimento_message_id=x_riferimento,
x_ricevuta=x_ricevuta,
x_tipo_ricevuta=x_tipo,
is_receipt=is_receipt,
)
def _clean_header(value: str | None) -> str | None:
"""Pulisce e normalizza il valore di un header PEC."""
if not value:
return None
return value.strip()
# ─── State machine messaggi outbound ─────────────────────────────────────────
#
# Quando il worker riceve una ricevuta PEC (inbound), deve aggiornare
# lo stato del messaggio outbound corrispondente (trovato tramite
# X-Riferimento-Message-ID → message_id_header).
#
# Transizioni valide:
# (current_state, receipt_type) → new_state
#
# Stati terminali: "delivered", "anomaly", "failed"
# Non si deve retrocedere di stato (es. delivered → anomaly non è valido)
# Tipo ricevuta → nuovo stato da applicare al messaggio outbound
_RECEIPT_TO_STATE: dict[str, str] = {
"accettazione": "accepted",
"presa_in_carico": "accepted", # equivalente funzionale ad accepted
"avvenuta_consegna": "delivered",
"non_accettazione": "anomaly",
"mancata_consegna": "anomaly",
"errore_consegna": "anomaly",
"preavviso_mancata_consegna": "anomaly",
"rilevazione_virus": "anomaly",
}
# Transizioni di stato valide per ciascuno stato corrente
# Solo le transizioni in avanti sono permesse (no retrocessioni)
VALID_OUTBOUND_TRANSITIONS: dict[str, set[str]] = {
"queued": {"accepted", "anomaly"},
"sent": {"accepted", "anomaly"},
"accepted": {"delivered", "anomaly"},
# delivered e anomaly sono terminali: nessuna transizione
}
def get_state_transition(pec_type: str) -> str | None:
"""
Restituisce il nuovo stato da applicare al messaggio outbound
quando arriva una ricevuta di questo tipo.
Ritorna None se il tipo non provoca transizioni di stato
(es. 'posta_certificata' non è una ricevuta).
"""
return _RECEIPT_TO_STATE.get(pec_type)
def apply_outbound_transition(current_state: str, pec_type: str) -> str | None:
"""
Applica la state machine al messaggio outbound.
Args:
current_state: stato corrente del messaggio outbound
pec_type: tipo della ricevuta arrivata
Returns:
Il nuovo stato se la transizione è valida, None altrimenti.
Ritorna None anche se lo stato è già terminale.
"""
new_state = get_state_transition(pec_type)
if new_state is None:
return None # Non è una ricevuta con effetto sullo stato
valid_targets = VALID_OUTBOUND_TRANSITIONS.get(current_state, set())
if new_state in valid_targets:
return new_state
logger.debug(
f"Transizione stato ignorata: {current_state!r}{new_state!r} "
f"(ricevuta: {pec_type!r})"
)
return None
+174
View File
@@ -0,0 +1,174 @@
"""
Estrazione EML annidato nelle ricevute PEC (EML-in-EML) Fase 3.
Una ricevuta PEC è strutturalmente un messaggio multipart che contiene:
- text/plain → testo leggibile della ricevuta
- application/xml → dati strutturati XML (daticert.xml, opzionale)
- message/rfc822 → il messaggio originale allegato (o la busta PEC)
Questo modulo estrae e analizza l'EML annidato per identificare
il messaggio originale a cui si riferisce la ricevuta.
Struttura tipica ricevuta Aruba PEC:
multipart/mixed
├── text/plain (descrizione ricevuta)
├── application/xml (daticert.xml campi strutturati)
└── message/rfc822 (messaggio originale)
├── From: mittente@pec.it
├── To: destinatario@pec.it
└── Subject: ...
"""
import email
import email.header
import email.message
import email.utils
import logging
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class NestedEmlInfo:
"""Informazioni estratte dall'EML annidato in una ricevuta PEC."""
raw_bytes: bytes
message_id: str | None
subject: str | None
from_address: str | None
to_addresses: list[str] = field(default_factory=list)
def extract_nested_eml(msg: email.message.Message) -> NestedEmlInfo | None:
"""
Estrae l'EML annidato (message/rfc822) da una ricevuta PEC.
Naviga il messaggio alla ricerca della prima parte di tipo message/rfc822
e restituisce le informazioni estratte dall'EML interno.
Returns:
NestedEmlInfo se trovato un EML annidato, None altrimenti.
"""
if not msg.is_multipart():
return None
for part in msg.walk():
if part.get_content_type() != "message/rfc822":
continue
try:
payload = part.get_payload()
inner_bytes: bytes | None = None
inner_msg: email.message.Message | None = None
if isinstance(payload, list) and payload:
# Forma canonica RFC: payload è lista di Message
candidate = payload[0]
if isinstance(candidate, email.message.Message):
inner_msg = candidate
inner_bytes = candidate.as_bytes()
elif isinstance(payload, bytes):
inner_bytes = payload
try:
inner_msg = email.message_from_bytes(payload)
except Exception:
pass
elif isinstance(payload, str):
inner_bytes = payload.encode("utf-8", errors="replace")
try:
inner_msg = email.message_from_bytes(inner_bytes)
except Exception:
pass
if inner_bytes and inner_msg:
return _extract_info_from_inner(inner_msg, inner_bytes)
except Exception as exc:
logger.warning(f"Errore estrazione EML annidato: {exc}")
return None
def extract_receipt_xml(msg: email.message.Message) -> str | None:
"""
Estrae il contenuto XML strutturato da una ricevuta PEC.
Le ricevute PEC conformi al DM 2 novembre 2005 contengono un allegato XML
(tipicamente "daticert.xml") con i campi strutturati della ricevuta:
tipo, timestamp, identificatore messaggio, mittente, destinatario, ecc.
Returns:
Stringa XML se trovata, None altrimenti.
"""
for part in msg.walk():
ct = part.get_content_type()
if ct not in ("application/xml", "text/xml"):
continue
# Verifica che sia il daticert (opzionale: controlla filename)
filename = part.get_filename() or part.get_param("name") or ""
# Accetta qualsiasi XML nelle ricevute PEC (non solo daticert.xml)
try:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
except Exception as exc:
logger.debug(f"Errore estrazione XML ricevuta (filename={filename!r}): {exc}")
return None
# ─── Helper privati ───────────────────────────────────────────────────────────
def _extract_info_from_inner(
msg: email.message.Message,
raw_bytes: bytes,
) -> NestedEmlInfo:
"""Estrae i campi identificativi dall'EML annidato."""
message_id = msg.get("Message-ID", "").strip() or None
subject = _decode_hdr(msg.get("Subject"))
from_address = email.utils.parseaddr(msg.get("From", ""))[1] or None
to_addresses = _extract_addrs(msg.get("To"))
return NestedEmlInfo(
raw_bytes=raw_bytes,
message_id=message_id,
subject=subject,
from_address=from_address,
to_addresses=to_addresses,
)
def _decode_hdr(value: str | None) -> str | None:
"""Decodifica un header RFC 2047."""
if not value:
return None
try:
parts = email.header.decode_header(value)
decoded = []
for part, charset in parts:
if isinstance(part, bytes):
decoded.append(part.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(str(part))
return "".join(decoded).strip()
except Exception:
return str(value)
def _extract_addrs(field_value: str | None) -> list[str]:
"""Estrae lista di indirizzi email da un campo header."""
if not field_value:
return []
try:
parsed = email.utils.getaddresses([field_value])
return [addr for _, addr in parsed if addr]
except Exception:
return []
+75
View File
@@ -59,6 +59,81 @@ async def upload_eml(
raise
async def upload_attachment(
tenant_id: str,
mailbox_id: str,
message_id: str,
filename: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> str:
"""
Carica un allegato su MinIO e restituisce il percorso oggetto.
Percorso: tenants/{tenant_id}/mailboxes/{mailbox_id}/attachments/{message_id}/{filename}
Args:
tenant_id: UUID del tenant
mailbox_id: UUID della casella
message_id: UUID del messaggio a cui appartiene l'allegato
filename: nome file dell'allegato (usato nel path)
content: byte del file
content_type: MIME type del file
Returns:
Percorso oggetto su MinIO (senza il nome bucket)
"""
client = get_minio_client()
bucket = settings.minio_bucket
# Sanitizza il filename per evitare path traversal
safe_filename = _sanitize_filename(filename)
object_path = (
f"tenants/{tenant_id}/mailboxes/{mailbox_id}"
f"/attachments/{message_id}/{safe_filename}"
)
try:
data_stream = io.BytesIO(content)
await client.put_object(
bucket_name=bucket,
object_name=object_path,
data=data_stream,
length=len(content),
content_type=content_type,
)
logger.debug(
f"Allegato caricato: s3://{bucket}/{object_path} "
f"({len(content)} bytes, {content_type})"
)
return object_path
except Exception as e:
logger.error(f"Errore upload allegato {object_path}: {e}")
raise
def _sanitize_filename(filename: str) -> str:
"""
Sanitizza il nome file per uso sicuro come path MinIO.
Rimuove caratteri pericolosi mantenendo l'estensione originale.
"""
import re
# Rimuovi path separators e null bytes
safe = filename.replace("/", "_").replace("\\", "_").replace("\x00", "")
# Rimuovi caratteri non ASCII e di controllo
safe = re.sub(r"[^\w.\-() ]", "_", safe, flags=re.UNICODE)
# Limita la lunghezza (MinIO ha limite 1024 chars per object name)
if len(safe) > 200:
# Mantieni estensione
parts = safe.rsplit(".", 1)
if len(parts) == 2:
safe = parts[0][:196] + "." + parts[1]
else:
safe = safe[:200]
return safe or "attachment"
async def ensure_bucket_exists() -> None:
"""Verifica che il bucket MinIO esista, altrimenti lo crea."""
client = get_minio_client()