Modifiche varie

This commit is contained in:
2026-06-04 20:54:49 +02:00
parent ccc4167e28
commit e31676d22e
31 changed files with 3058 additions and 153 deletions
+105 -13
View File
@@ -528,6 +528,8 @@ async def _save_message(
- Salvataggio allegati su MinIO + tabella attachments
- State machine outbound: solo per messaggi inbound (ricevute PEC)
- Collegamento parent_message_id via X-Riferimento-Message-ID
- Dedup outbound: evita duplicati quando un messaggio inviato via send_pec
viene poi trovato anche nella cartella Sent del server IMAP
"""
# ── Idempotenza: chiave composta (mailbox_id + imap_uid + imap_folder) ────
existing = await db.execute(
@@ -552,17 +554,73 @@ async def _save_message(
parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt)
received_at = datetime.now(UTC)
# ── Dedup outbound: upsert sul record send_pec invece di creare duplicato ─
# Problema: send_pec crea un record outbound con imap_uid=NULL e poi
# la sync della cartella Sent trova lo stesso messaggio e vorrebbe creare
# un secondo record con lo stesso message_id_header. I duplicati rompono
# il binding delle ricevute (_apply_outbound_state_machine usava
# scalar_one_or_none() che esplode con MultipleResultsFound).
# Soluzione: se esiste già un record outbound con lo stesso message_id_header
# e imap_uid=NULL (il record canonico di send_pec), aggiorniamo quel record
# con l'imap_uid/imap_folder della Sent folder invece di crearne uno nuovo.
if direction == "outbound" and parsed.message_id:
existing_outbound = await db.execute(
select(Message).where(
Message.mailbox_id == mailbox.id,
Message.message_id_header == parsed.message_id,
Message.direction == "outbound",
Message.imap_uid.is_(None),
)
)
send_pec_record = existing_outbound.scalar_one_or_none()
if send_pec_record:
# Aggiorna il record esistente con i dati IMAP della cartella Sent
send_pec_record.imap_uid = uid
send_pec_record.imap_folder = imap_folder
send_pec_record.updated_at = datetime.now(UTC)
# Aggiorna anche il raw_eml_path se non è già impostato
if not send_pec_record.raw_eml_path:
try:
eml_path = await upload_eml(
tenant_id=str(mailbox.tenant_id),
mailbox_id=str(mailbox.id),
uid=uid,
eml_bytes=raw_eml,
)
send_pec_record.raw_eml_path = eml_path
except Exception as e:
logger.warning(
f"[{mailbox.email_address}] Upload EML MinIO per record send_pec "
f"UID {uid}: {e}"
)
await db.flush()
logger.info(
f"[{mailbox.email_address}] Sent-sync: aggiornato record send_pec "
f"message_id={parsed.message_id!r} con imap_uid={uid} "
f"folder={imap_folder!r} (evitato duplicato outbound)"
)
return True
# ── State machine: trova e aggiorna messaggio outbound ────────────────────
# Solo per messaggi inbound che sono ricevute PEC (non per posta inviata)
parent_message_id: uuid.UUID | None = None
if direction == "inbound" and 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,
)
try:
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,
)
except Exception as bind_err:
logger.error(
f"[{mailbox.email_address}] [receipt-binding] Errore aggiornamento stato "
f"outbound per ricevuta UID={uid} tipo={pec_class.pec_type!r}: {bind_err}",
exc_info=True,
)
# Non interrompere il salvataggio della ricevuta: il record viene
# comunque inserito, ma senza parent_message_id.
# ── Upload raw EML su MinIO ───────────────────────────────────────────────
eml_path: str | None = None
@@ -700,6 +758,12 @@ async def _apply_outbound_state_machine(
Cerca il messaggio outbound con message_id_header == riferimento_message_id,
applica la transizione di stato se valida.
Gestisce il caso di messaggi outbound duplicati (uno creato da send_pec con
imap_uid=NULL e uno creato dalla sync della cartella Sent): in caso di multipli,
prioritizza quello con imap_uid=NULL (il record canonico creato da send_pec).
Il dedup in _save_message riduce drasticamente la probabilità di multipli,
ma questa funzione gestisce anche i casi residui per robustezza.
Returns:
UUID del messaggio originale se trovato, None altrimenti.
"""
@@ -710,15 +774,37 @@ async def _apply_outbound_state_machine(
Message.direction == "outbound",
)
)
parent_msg = result.scalar_one_or_none()
candidates = result.scalars().all()
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)"
if not candidates:
logger.warning(
f"[receipt-binding] Messaggio outbound non trovato per "
f"riferimento={riferimento_message_id!r} (ricevuta: {pec_type!r}). "
f"Potrebbe essere stato inviato da un client esterno o il message_id_header "
f"non e' ancora stato persistito."
)
return None
# In presenza di duplicati (es. record send_pec + record Sent-sync),
# prioritizza il messaggio con imap_uid=NULL (quello canonico di send_pec).
parent_msg: Message | None = None
if len(candidates) == 1:
parent_msg = candidates[0]
else:
logger.warning(
f"[receipt-binding] Trovati {len(candidates)} messaggi outbound con "
f"message_id_header={riferimento_message_id!r}. "
f"Prioritizzo il record con imap_uid=NULL (send_pec)."
)
# Priorità 1: imap_uid IS NULL (creato da send_pec)
for m in candidates:
if m.imap_uid is None:
parent_msg = m
break
# Priorità 2: qualsiasi altro (creato dalla sync Sent)
if parent_msg is None:
parent_msg = candidates[0]
new_state = apply_outbound_transition(parent_msg.state, pec_type)
if new_state:
old_state = parent_msg.state
@@ -726,8 +812,14 @@ async def _apply_outbound_state_machine(
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})"
f"[receipt-binding] State machine outbound: {riferimento_message_id!r} "
f"{old_state!r} -> {new_state!r} (ricevuta: {pec_type!r}, "
f"msg_id={parent_msg.id})"
)
else:
logger.debug(
f"[receipt-binding] Nessuna transizione valida per {riferimento_message_id!r} "
f"state={parent_msg.state!r} ricevuta={pec_type!r}"
)
return parent_msg.id
+27 -8
View File
@@ -7,14 +7,17 @@ Questo job viene usato per:
- Retry dopo un errore (called dal pool monitor)
Non sostituisce il loop IMAP continuo (IMAPConnection); è un one-shot job.
Sincronizza sia INBOX (per rilevare ricevute PEC e messaggi in arrivo)
sia la cartella Sent (per aggiornare imap_uid sul record send_pec ed
evitare duplicati outbound che rompono il binding delle ricevute).
"""
import logging
from typing import Any
from app.database import AsyncSessionLocal
from app.imap.reconnect import ExponentialBackoff
from app.imap.sync import sync_new_messages
from app.imap.sync import sync_new_messages, sync_sent_messages
from app.models import Mailbox
logger = logging.getLogger(__name__)
@@ -22,7 +25,7 @@ logger = logging.getLogger(__name__)
async def sync_mailbox(ctx: dict[str, Any], mailbox_id: str) -> dict:
"""
Job arq: sincronizza una singola casella PEC.
Job arq: sincronizza una singola casella PEC (INBOX + Sent).
Args:
ctx: contesto arq (contiene redis, pool reference)
@@ -50,28 +53,44 @@ async def sync_mailbox(ctx: dict[str, Any], mailbox_id: str) -> dict:
creds = IMAPConnection._decrypt_creds(mailbox)
try:
from app.imap.connection import IMAPConnection
conn = IMAPConnection(mailbox_id=mailbox_id, redis_client=redis_client)
client = await conn._connect(creds)
n = await sync_new_messages(client, mailbox, db, redis_client)
# Sync INBOX: ricevute PEC e messaggi in arrivo
n_inbox = await sync_new_messages(client, mailbox, db, redis_client)
# Sync Sent: aggiorna imap_uid sui record send_pec e previene duplicati.
# Fondamentale per il corretto binding delle ricevute PEC successive.
n_sent = 0
try:
n_sent = await sync_sent_messages(client, mailbox, db, redis_client)
except Exception as sent_err:
logger.warning(
f"[sync_mailbox] {mailbox.email_address} errore sync Sent "
f"(non critico): {sent_err}"
)
try:
await client.logout()
except Exception:
pass
logger.info(
f"[sync_mailbox] {mailbox.email_address}: "
f"INBOX={n_inbox} nuovi, Sent={n_sent} nuovi"
)
return {
"status": "ok",
"mailbox": mailbox.email_address,
"new_messages": n,
"new_messages_inbox": n_inbox,
"new_messages_sent": n_sent,
}
except Exception as e:
logger.error(f"[sync_mailbox] {mailbox_id} errore: {e}", exc_info=True)
return {
"status": "error",
"mailbox": mailbox.email_address,
"mailbox": mailbox.email_address if mailbox else mailbox_id,
"message": str(e),
}