This commit is contained in:
2026-04-07 11:32:05 +02:00
103 changed files with 14789 additions and 555 deletions
+4
View File
@@ -5,6 +5,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
curl \
tesseract-ocr \
tesseract-ocr-ita \
tesseract-ocr-eng \
poppler-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /worker
+55 -5
View File
@@ -35,6 +35,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.jobs.dispatch_notification import evaluate_and_enqueue_notifications
from app.jobs.index_message import index_message
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
@@ -539,11 +541,15 @@ async def _save_message(
logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip")
return False
# ── Parsing completo EML ──────────────────────────────────────────────────
parsed = parse_eml(raw_eml)
pec_class = classify_pec_message(
parsed.raw_message or email.message_from_bytes(raw_eml)
)
# ── Classificazione PEC da header (veloce, senza body) ───────────────────
# La classificazione avviene PRIMA del parsing completo perche' il parser
# deve sapere se il messaggio e' una ricevuta per evitare di sovrascrivere
# il body_text (testo della ricevuta) con il contenuto di postacert.eml.
quick_msg = email.message_from_bytes(raw_eml)
pec_class = classify_pec_message(quick_msg)
# ── Parsing completo EML (con is_receipt per proteggere il body) ──────────
parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt)
received_at = datetime.now(UTC)
# ── State machine: trova e aggiorna messaggio outbound ────────────────────
@@ -635,6 +641,50 @@ async def _save_message(
f"direction={direction!r} pec_type={pec_class.pec_type!r} "
f"subject={message.subject!r} allegati={len(parsed.attachments)}"
)
# ── Indicizzazione full-text (non bloccante, non interrompe la sync) ─────
# Chiamata dopo il flush degli allegati: index_message puo' leggere
# sia il messaggio che gli allegati dalla sessione corrente.
await index_message(message.id, db)
# ── Valutazione e accodamento notifiche (non bloccante) ───────────────────
# Solo per messaggi inbound: le ricevute PEC e la posta in arrivo
# possono triggerare regole di notifica configurate dal tenant.
# I messaggi outbound (Sent) non generano notifiche automatiche.
if direction == "inbound":
await evaluate_and_enqueue_notifications(
message=message,
mailbox=mailbox,
db=db,
redis_client=redis_client,
)
# ── Regole di smistamento automatico (Feature 2) ──────────────────────────
# Solo per messaggi inbound posta_certificata (non ricevute di sistema).
if direction == "inbound" and pec_class.pec_type == "posta_certificata":
try:
await redis_client.enqueue_job("apply_routing_rules", str(message.id))
except Exception as e:
logger.warning(f"[{mailbox.email_address}] Impossibile enqueue apply_routing_rules: {e}")
# ── Auto-save mittente nella rubrica (Feature 6) ──────────────────────────
# Per messaggi inbound di tipo posta_certificata, salva automaticamente
# il mittente nella rubrica pec_contacts del tenant (upsert idempotente).
if direction == "inbound" and pec_class.pec_type == "posta_certificata" and message.from_address:
try:
from sqlalchemy import text as _text
await db.execute(
_text("""
INSERT INTO pec_contacts (id, tenant_id, email, auto_saved, created_at, updated_at)
VALUES (gen_random_uuid(), :tenant_id, :email, true, now(), now())
ON CONFLICT (tenant_id, email) DO NOTHING
"""),
{"tenant_id": str(mailbox.tenant_id), "email": message.from_address.lower().strip()},
)
await db.flush()
except Exception as e:
logger.debug(f"[{mailbox.email_address}] Auto-save contatto fallito (non critico): {e}")
return True
+234
View File
@@ -0,0 +1,234 @@
"""
Job arq: apply_routing_rules applica le regole di smistamento automatico.
Viene accodato da sync.py dopo il salvataggio di ogni messaggio inbound.
Logica:
1. Carica le regole attive del tenant ordinate per priority
2. Per ogni regola valuta le condizioni (AND)
3. Se match: esegue le azioni (apply_label, mark_read, mark_starred, notify_webhook)
4. Se stop_processing=True, interrompe la catena
"""
import logging
import re
import uuid as uuid_module
from typing import Any
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import Message
logger = logging.getLogger(__name__)
# ─── Job principale ───────────────────────────────────────────────────────────
async def apply_routing_rules(ctx: dict[str, Any], message_id: str) -> dict:
"""
Valuta le regole di smistamento automatico per un messaggio.
Args:
ctx: contesto arq
message_id: UUID del messaggio da processare
Returns:
dict con: matched_rules, actions_applied
"""
msg_uuid = uuid_module.UUID(message_id)
async with AsyncSessionLocal() as db:
# Carica il messaggio
msg = await db.get(Message, msg_uuid)
if not msg:
logger.warning(f"[routing_rules] Messaggio {message_id} non trovato")
return {"status": "skipped", "reason": "message_not_found"}
# Solo messaggi inbound di tipo posta_certificata
if msg.direction != "inbound" or msg.pec_type != "posta_certificata":
return {"status": "skipped", "reason": "not_inbound_pec"}
# Carica regole attive del tenant ordinate per priority ASC
rules_result = await db.execute(
text("""
SELECT r.id, r.name, r.priority, r.stop_processing,
COALESCE(
json_agg(
json_build_object('field', c.field, 'operator', c.operator, 'value', c.value)
ORDER BY c.id
) FILTER (WHERE c.id IS NOT NULL),
'[]'::json
) AS conditions,
COALESCE(
json_agg(
json_build_object('action_type', a.action_type, 'action_value', a.action_value)
ORDER BY a.id
) FILTER (WHERE a.id IS NOT NULL),
'[]'::json
) AS actions
FROM routing_rules r
LEFT JOIN routing_rule_conditions c ON c.rule_id = r.id
LEFT JOIN routing_rule_actions a ON a.rule_id = r.id
WHERE r.tenant_id = :tenant_id
AND r.is_active = true
GROUP BY r.id, r.name, r.priority, r.stop_processing
ORDER BY r.priority ASC
"""),
{"tenant_id": str(msg.tenant_id)},
)
rules = rules_result.mappings().all()
matched_count = 0
actions_applied: list[str] = []
for rule in rules:
conditions = rule["conditions"]
if not conditions:
continue
# Valuta condizioni (AND)
if not _evaluate_conditions(msg, conditions):
continue
matched_count += 1
logger.info(
f"[routing_rules] Regola '{rule['name']}' (priority={rule['priority']}) "
f"match per messaggio {message_id}"
)
# Esegui azioni
for action in rule["actions"]:
applied = await _apply_action(db, msg, action["action_type"], action["action_value"])
if applied:
actions_applied.append(action["action_type"])
if rule["stop_processing"]:
break
if matched_count > 0:
await db.commit()
return {
"status": "ok",
"message_id": message_id,
"matched_rules": matched_count,
"actions_applied": actions_applied,
}
# ─── Valutazione condizioni ────────────────────────────────────────────────────
def _get_field_value(msg: Message, field: str) -> str:
if field == "from_address":
return (msg.from_address or "").lower()
elif field == "to_address":
return " ".join(msg.to_addresses or []).lower()
elif field == "subject":
return (msg.subject or "").lower()
elif field == "mailbox_id":
return str(msg.mailbox_id)
elif field == "pec_type":
return msg.pec_type or ""
return ""
def _evaluate_condition(field_value: str, operator: str, value: str) -> bool:
v = value.lower()
fv = field_value.lower()
if operator == "contains":
return v in fv
elif operator == "not_contains":
return v not in fv
elif operator == "equals":
return fv == v
elif operator == "starts_with":
return fv.startswith(v)
elif operator == "ends_with":
return fv.endswith(v)
elif operator == "regex":
try:
return bool(re.search(value, field_value, re.IGNORECASE))
except re.error:
return False
return False
def _evaluate_conditions(msg: Message, conditions: list[dict]) -> bool:
"""Valuta AND tra tutte le condizioni."""
for cond in conditions:
field_val = _get_field_value(msg, cond["field"])
if not _evaluate_condition(field_val, cond["operator"], cond["value"]):
return False
return True
# ─── Esecuzione azioni ─────────────────────────────────────────────────────────
async def _apply_action(
db: AsyncSession,
msg: Message,
action_type: str,
action_value: str | None,
) -> bool:
"""Esegue una singola azione. Restituisce True se applicata."""
try:
if action_type == "apply_label" and action_value:
return await _action_apply_label(db, msg, uuid_module.UUID(action_value))
elif action_type == "mark_read":
if not msg.is_read:
msg.is_read = True
return True
elif action_type == "mark_starred":
if not msg.is_starred:
msg.is_starred = True
return True
elif action_type == "notify_webhook" and action_value:
await _action_notify_webhook(msg, action_value)
return True
except Exception as e:
logger.warning(f"[routing_rules] Errore azione {action_type}: {e}")
return False
async def _action_apply_label(
db: AsyncSession, msg: Message, label_id: uuid_module.UUID
) -> bool:
"""Applica un'etichetta al messaggio se non gia' applicata."""
# Verifica che la label esista e appartenga al tenant
label_check = await db.execute(
text("SELECT id FROM labels WHERE id = :lid AND tenant_id = :tid"),
{"lid": str(label_id), "tid": str(msg.tenant_id)},
)
if not label_check.fetchone():
return False
# Inserisci con ON CONFLICT DO NOTHING per idempotenza
await db.execute(
text("""
INSERT INTO message_labels (message_id, label_id)
VALUES (:msg_id, :label_id)
ON CONFLICT DO NOTHING
"""),
{"msg_id": str(msg.id), "label_id": str(label_id)},
)
logger.debug(f"[routing_rules] Etichetta {label_id} applicata a {msg.id}")
return True
async def _action_notify_webhook(msg: Message, url: str) -> None:
"""Invia notifica webhook per il messaggio."""
import aiohttp
payload = {
"event": "routing_rule_match",
"message_id": str(msg.id),
"subject": msg.subject,
"from_address": msg.from_address,
"pec_type": msg.pec_type,
}
try:
async with aiohttp.ClientSession() as session:
await session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=5))
except Exception as e:
logger.warning(f"[routing_rules] Webhook {url} fallito: {e}")
+666
View File
@@ -0,0 +1,666 @@
"""
Job arq: dispatch_notification invio effettivo delle notifiche multi-canale.
Flusso completo:
1. sync.py salva un nuovo messaggio inbound
2. sync.py chiama evaluate_and_enqueue_notifications() (questa funzione)
3. evaluate_and_enqueue_notifications():
- legge le NotificationRule attive del tenant con event_type="new_message"
- applica i filtri opzionali (mailbox_id, direction, pec_type)
- per ogni regola che matcha: crea NotificationLog(status=pending)
- enqueue del job arq dispatch_notification con defer 5s
(per dare tempo al DB di committare prima che il job legga)
4. dispatch_notification(ctx, notification_log_id):
- legge NotificationLog + NotificationChannel dal DB
- controlla circuit breaker
- decifra config_enc con AES-256-GCM
- chiama il sender appropriato (telegram/webhook/email/whatsapp)
- aggiorna status: sent / failed
- in caso di fallimento: re-enqueue con backoff esponenziale
- aggiorna circuit breaker dopo fallimenti consecutivi
Circuit breaker:
- 5+ fallimenti consecutivi → apre per 1 ora
- reset automatico al primo successo
Retry backoff (max_attempts default=3):
Tentativo 1 fallisce → attendi 5 min → tentativo 2
Tentativo 2 fallisce → attendi 30 min → tentativo 3
Tentativo 3 fallisce → FAILED definitivo
Cifratura config_enc:
AES-256-GCM stesso schema di notification_service.py nel backend.
Backward compatible: fallback a base64 grezzo per configurazioni pre-fix.
"""
import base64
import json
import logging
import os
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.database import AsyncSessionLocal
from app.models import (
Mailbox,
Message,
NotificationChannel,
NotificationLog,
NotificationRule,
)
logger = logging.getLogger(__name__)
settings = get_settings()
# ─── Backoff retry (secondi) per tentativo N fallito (0-based) ───────────────
_RETRY_DELAYS = [
5 * 60, # dopo tentativo 1: 5 minuti
30 * 60, # dopo tentativo 2: 30 minuti
120 * 60, # dopo tentativo 3: 2 ore (non raggiunto: max_attempts=3)
]
# ─── Circuit breaker ──────────────────────────────────────────────────────────
_CIRCUIT_FAILURE_THRESHOLD = 5 # fallimenti consecutivi per aprire il circuito
_CIRCUIT_OPEN_DURATION = timedelta(hours=1)
# ─── Cifratura AES-256-GCM ────────────────────────────────────────────────────
def _decrypt_config(enc: str) -> dict:
"""
Decifra config_enc AES-256-GCM.
Backward compatible: se non e' GCM valido, prova fallback base64 grezzo.
"""
key = settings.encryption_key_bytes
try:
raw = base64.b64decode(enc.encode("ascii"))
if len(raw) > 28: # 12 nonce + 16 tag minimo
nonce = raw[:12]
ciphertext = raw[12:]
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
return json.loads(plaintext.decode("utf-8"))
except Exception:
pass
# Fallback: base64 grezzo (configurazioni pre-fix sicurezza)
try:
raw = base64.b64decode(enc.encode("ascii"))
return json.loads(raw.decode("utf-8"))
except Exception:
return {}
# ─── Valutazione filtri regola ────────────────────────────────────────────────
def _matches_filter(
filter_data: dict | None,
message: Message,
mailbox: Mailbox,
) -> bool:
"""
Valuta se un messaggio soddisfa i filtri opzionali di una regola.
Filtri supportati:
mailbox_id: str | list[str] UUID casella
direction: str "inbound" | "outbound"
pec_type: str | list[str] tipo messaggio PEC
"""
if not filter_data:
return True
# Filtro per mailbox
if "mailbox_id" in filter_data:
allowed = filter_data["mailbox_id"]
if isinstance(allowed, list):
if str(message.mailbox_id) not in [str(a) for a in allowed]:
return False
else:
if str(message.mailbox_id) != str(allowed):
return False
# Filtro per direzione
if "direction" in filter_data:
if message.direction != filter_data["direction"]:
return False
# Filtro per tipo PEC
if "pec_type" in filter_data:
allowed = filter_data["pec_type"]
if isinstance(allowed, list):
if message.pec_type not in allowed:
return False
else:
if message.pec_type != allowed:
return False
return True
# ─── Costruzione testo notifica ───────────────────────────────────────────────
def _build_notification_text(
event_type: str,
payload: dict,
channel_type: str,
channel_name: str = "",
) -> str:
"""
Costruisce il testo della notifica in base all'evento e al tipo canale.
"""
if event_type == "new_message":
subject = payload.get("subject") or "(senza oggetto)"
from_addr = payload.get("from_address") or "mittente sconosciuto"
mailbox_email = payload.get("mailbox_email") or payload.get("mailbox_id", "")
pec_type = payload.get("pec_type", "posta_certificata")
received_at = payload.get("received_at", "")
# Traduzione tipo PEC
pec_type_labels = {
"posta_certificata": "PEC",
"accettazione": "Ricevuta di Accettazione",
"non_accettazione": "Ricevuta di Non Accettazione",
"presa_in_carico": "Ricevuta di Presa in Carico",
"avvenuta_consegna": "Ricevuta di Avvenuta Consegna",
"mancata_consegna": "Ricevuta di Mancata Consegna",
"errore_consegna": "Ricevuta di Errore Consegna",
"preavviso_mancata_consegna": "Preavviso Mancata Consegna",
"rilevazione_virus": "Rilevazione Virus",
"unknown": "Messaggio",
}
tipo_label = pec_type_labels.get(pec_type, pec_type)
if channel_type == "telegram":
return (
f"<b>Nuovo messaggio PEC</b>\n\n"
f"<b>Tipo:</b> {tipo_label}\n"
f"<b>Da:</b> {from_addr}\n"
f"<b>Casella:</b> {mailbox_email}\n"
f"<b>Oggetto:</b> {subject}"
)
else:
return (
f"Nuovo messaggio PEC\n"
f"Tipo: {tipo_label}\n"
f"Da: {from_addr}\n"
f"Casella: {mailbox_email}\n"
f"Oggetto: {subject}"
)
elif event_type == "state_changed":
message_id = payload.get("message_id", "")
old_state = payload.get("old_state", "")
new_state = payload.get("new_state", "")
subject = payload.get("subject", "")
return (
f"Cambio stato PEC\n"
f"Oggetto: {subject}\n"
f"Stato: {old_state} -> {new_state}"
)
else:
return f"Evento PEChub: {event_type}\n{json.dumps(payload, ensure_ascii=False, default=str)}"
def _build_notification_payload(event_type: str, payload: dict) -> dict:
"""Costruisce il payload JSON per il webhook."""
return {
"event": event_type,
"timestamp": datetime.now(UTC).isoformat(),
"data": payload,
}
def _build_email_subject(event_type: str, payload: dict) -> str:
"""Costruisce l'oggetto dell'email di notifica."""
if event_type == "new_message":
subject = payload.get("subject") or "(senza oggetto)"
return f"[PEChub] Nuovo messaggio PEC: {subject}"
elif event_type == "state_changed":
return f"[PEChub] Cambio stato PEC: {payload.get('new_state', '')}"
return f"[PEChub] Evento: {event_type}"
# ─── Evaluate & Enqueue ───────────────────────────────────────────────────────
async def evaluate_and_enqueue_notifications(
message: Message,
mailbox: Mailbox,
db: Any, # AsyncSession evito import circolare
redis_client: Any, # ArqRedis
) -> None:
"""
Valuta le regole di notifica per un messaggio appena salvato e accoda i job.
Chiamata da sync.py dopo _save_message e index_message.
Non solleva eccezioni: gli errori vengono loggati ma non propagati per
non interrompere il flusso di sincronizzazione IMAP.
Args:
message: messaggio appena salvato nel DB (flush, non commit)
mailbox: casella di appartenenza
db: sessione DB (open, con flush del messaggio)
redis_client: ArqRedis per enqueue_job
"""
try:
await _do_evaluate_and_enqueue(message, mailbox, db, redis_client)
except Exception as exc:
logger.error(
f"Errore evaluate_and_enqueue_notifications per messaggio "
f"{message.id}: {exc}",
exc_info=True,
)
async def _do_evaluate_and_enqueue(
message: Message,
mailbox: Mailbox,
db: Any,
redis_client: Any,
) -> None:
"""Logica interna puo' sollevare eccezioni."""
# Carica regole attive per questo tenant con event_type = "new_message"
rules_result = await db.execute(
select(NotificationRule)
.options(selectinload(NotificationRule.channel))
.where(
NotificationRule.tenant_id == message.tenant_id,
NotificationRule.is_active == True, # noqa: E712
NotificationRule.event_type == "new_message",
)
)
rules: list[NotificationRule] = list(rules_result.scalars().all())
if not rules:
return
enqueued_count = 0
for rule in rules:
channel = rule.channel
if not channel or not channel.is_active:
continue
# Controlla circuit breaker
if (
channel.circuit_open_until
and channel.circuit_open_until > datetime.now(UTC)
):
logger.debug(
f"[notify] Canale {channel.name!r} circuit aperto fino a "
f"{channel.circuit_open_until}, skip regola {rule.name!r}"
)
continue
# Applica filtri
if not _matches_filter(rule.filter, message, mailbox):
continue
# Costruisce il payload dell'evento
event_payload = {
"message_id": str(message.id),
"mailbox_id": str(mailbox.id),
"mailbox_email": mailbox.email_address,
"subject": message.subject or "",
"from_address": message.from_address or "",
"to_addresses": list(message.to_addresses or []),
"pec_type": message.pec_type,
"direction": message.direction,
"received_at": (
message.received_at.isoformat()
if message.received_at
else None
),
}
# Crea NotificationLog
log = NotificationLog(
id=uuid.uuid4(),
tenant_id=message.tenant_id,
channel_id=rule.channel_id,
rule_id=rule.id,
event_type="new_message",
event_payload=event_payload,
status="pending",
attempt_count=0,
max_attempts=3,
)
db.add(log)
await db.flush() # ottieni log.id
# Enqueue arq job con defer 5s per attendere il commit DB
try:
await redis_client.enqueue_job(
"dispatch_notification",
str(log.id),
_defer_by=timedelta(seconds=5),
)
enqueued_count += 1
logger.info(
f"[notify] Enqueued dispatch_notification per regola "
f"{rule.name!r} -> canale {channel.name!r} "
f"(log_id={log.id})"
)
except Exception as e:
logger.error(
f"[notify] Impossibile enqueue dispatch_notification "
f"per log {log.id}: {e}"
)
if enqueued_count > 0:
logger.info(
f"[notify] Messaggio {message.id}: "
f"{enqueued_count} notifiche accodate"
)
# ─── Job arq principale ───────────────────────────────────────────────────────
async def dispatch_notification(
ctx: dict[str, Any],
notification_log_id: str,
) -> dict:
"""
Job arq: legge un NotificationLog e invia la notifica al canale configurato.
Args:
ctx: contesto arq (ctx["redis"] = ArqRedis)
notification_log_id: UUID del NotificationLog da processare
Returns:
dict con status e dettagli
"""
redis_client = ctx.get("redis")
async with AsyncSessionLocal() as db:
log_uuid = uuid.UUID(notification_log_id)
# ── Carica log + canale ───────────────────────────────────────────────
log_result = await db.execute(
select(NotificationLog)
.options(selectinload(NotificationLog.channel))
.where(NotificationLog.id == log_uuid)
)
log: NotificationLog | None = log_result.scalar_one_or_none()
if not log:
logger.warning(
f"[dispatch_notification] NotificationLog {notification_log_id} non trovato"
)
return {"status": "not_found", "log_id": notification_log_id}
if log.status in ("sent", "failed", "skipped"):
logger.debug(
f"[dispatch_notification] Log {notification_log_id} "
f"gia' in stato {log.status!r}, skip"
)
return {"status": "already_processed", "current_status": log.status}
channel = log.channel
if not channel or not channel.is_active:
log.status = "skipped"
await db.commit()
return {"status": "skipped", "reason": "channel_inactive"}
# ── Circuit breaker ───────────────────────────────────────────────────
if (
channel.circuit_open_until
and channel.circuit_open_until > datetime.now(UTC)
):
log.status = "skipped"
await db.commit()
logger.info(
f"[dispatch_notification] Canale {channel.name!r} circuit aperto, "
f"log {notification_log_id} marcato skipped"
)
return {"status": "skipped", "reason": "circuit_open"}
# ── Incrementa contatore tentativi ────────────────────────────────────
log.attempt_count += 1
current_attempt = log.attempt_count
# ── Decifra config sensibile ──────────────────────────────────────────
secret: dict = {}
if channel.config_enc:
try:
secret = _decrypt_config(channel.config_enc)
except Exception as e:
log.status = "failed"
log.last_error = f"Errore decifratura config: {e}"
await db.commit()
logger.error(
f"[dispatch_notification] Errore decifratura canale "
f"{channel.name!r}: {e}"
)
return {"status": "failed", "error": str(e)}
config = channel.config or {}
payload = log.event_payload or {}
channel_type = channel.channel_type
# ── Testo notifica ────────────────────────────────────────────────────
notif_text = _build_notification_text(
event_type=log.event_type,
payload=payload,
channel_type=channel_type,
channel_name=channel.name,
)
# ── Dispatch al sender ────────────────────────────────────────────────
success = False
error_msg: str | None = None
http_status: int | None = None
try:
if channel_type == "telegram":
bot_token = secret.get("bot_token")
chat_id = str(config.get("chat_id", ""))
if not bot_token or not chat_id:
raise ValueError("bot_token o chat_id non configurati")
from app.notifications.telegram import send_message
result = await send_message(
bot_token=bot_token,
chat_id=chat_id,
text=notif_text,
parse_mode="HTML",
)
http_status = 200
success = True
logger.info(
f"[dispatch_notification] Telegram inviato: "
f"message_id={result.get('message_id')} "
f"canale={channel.name!r}"
)
elif channel_type == "webhook":
url = config.get("url")
if not url:
raise ValueError("URL webhook non configurato")
webhook_secret_val = secret.get("webhook_secret")
from app.notifications.webhook import send_webhook
result = await send_webhook(
url=url,
payload=_build_notification_payload(log.event_type, payload),
event_type=log.event_type,
webhook_secret=webhook_secret_val,
)
http_status = result.get("http_status")
success = True
logger.info(
f"[dispatch_notification] Webhook inviato: "
f"HTTP {http_status} delivery={result.get('delivery_id')} "
f"canale={channel.name!r}"
)
elif channel_type == "email":
smtp_host = config.get("smtp_host")
smtp_port = int(config.get("smtp_port", 465))
from_email = config.get("from_email")
to_email = config.get("to_email")
smtp_user = config.get("smtp_user") or from_email
use_tls = config.get("smtp_use_tls", True)
use_starttls = config.get("smtp_use_starttls", False)
from_name = config.get("from_name", "PEChub Notifiche")
smtp_password = secret.get("smtp_password", "")
if not smtp_host or not from_email or not to_email:
raise ValueError("Configurazione SMTP incompleta")
from app.notifications.email_smtp import send_email_notification
subject = _build_email_subject(log.event_type, payload)
await send_email_notification(
smtp_host=smtp_host,
smtp_port=smtp_port,
smtp_user=smtp_user,
smtp_password=smtp_password,
from_email=from_email,
to_email=to_email,
subject=subject,
body_text=notif_text,
body_html=None,
from_name=from_name,
use_tls=use_tls,
use_starttls=use_starttls,
)
http_status = 200
success = True
logger.info(
f"[dispatch_notification] Email inviata a {to_email} "
f"canale={channel.name!r}"
)
elif channel_type == "whatsapp":
phone_number_id = config.get("phone_number_id")
to_phone = config.get("to_phone")
access_token = secret.get("access_token")
if not phone_number_id or not to_phone or not access_token:
raise ValueError("Configurazione WhatsApp incompleta")
from app.notifications.whatsapp import send_whatsapp_message
result = await send_whatsapp_message(
phone_number_id=phone_number_id,
to_phone=to_phone,
text=notif_text,
access_token=access_token,
)
http_status = result.get("http_status")
success = True
logger.info(
f"[dispatch_notification] WhatsApp inviato: "
f"message_id={result.get('message_id')} "
f"canale={channel.name!r}"
)
else:
raise ValueError(f"Tipo canale non supportato: {channel_type!r}")
except Exception as exc:
error_msg = str(exc)
logger.warning(
f"[dispatch_notification] Tentativo {current_attempt} fallito "
f"canale={channel.name!r} tipo={channel_type!r}: {error_msg}"
)
# ── Aggiorna stato log e canale ───────────────────────────────────────
if success:
log.status = "sent"
log.sent_at = datetime.now(UTC)
log.http_status = http_status
# Reset circuit breaker
channel.consecutive_failures = 0
channel.circuit_open_until = None
await db.commit()
logger.info(
f"[dispatch_notification] Notifica {notification_log_id} INVIATA "
f"canale={channel.name!r} tipo={channel_type!r}"
)
return {
"status": "sent",
"log_id": notification_log_id,
"channel": channel.name,
"channel_type": channel_type,
"attempt": current_attempt,
}
else:
# ── Retry o failure definitivo ────────────────────────────────────
log.last_error = error_msg
log.http_status = http_status
if current_attempt < log.max_attempts:
# Calcola delay backoff
delay_idx = min(current_attempt - 1, len(_RETRY_DELAYS) - 1)
delay_seconds = _RETRY_DELAYS[delay_idx]
next_retry = datetime.now(UTC) + timedelta(seconds=delay_seconds)
log.next_retry_at = next_retry
# Mantieni status "pending" per il retry
await db.commit()
# Re-enqueue con backoff
if redis_client:
try:
await redis_client.enqueue_job(
"dispatch_notification",
notification_log_id,
_defer_by=timedelta(seconds=delay_seconds),
)
logger.info(
f"[dispatch_notification] Retry tentativo "
f"{current_attempt + 1} schedulato in {delay_seconds}s "
f"per log {notification_log_id}"
)
except Exception as enqueue_err:
logger.error(
f"[dispatch_notification] Impossibile re-enqueue "
f"log {notification_log_id}: {enqueue_err}"
)
return {
"status": "retry",
"log_id": notification_log_id,
"attempt": current_attempt,
"next_retry_in_seconds": delay_seconds,
"error": error_msg,
}
else:
# Tutti i tentativi esauriti → FAILED
log.status = "failed"
# Aggiorna circuit breaker canale
channel.consecutive_failures += 1
if channel.consecutive_failures >= _CIRCUIT_FAILURE_THRESHOLD:
channel.circuit_open_until = datetime.now(UTC) + _CIRCUIT_OPEN_DURATION
logger.warning(
f"[dispatch_notification] Circuit breaker aperto per "
f"canale {channel.name!r} "
f"({channel.consecutive_failures} fallimenti consecutivi)"
)
await db.commit()
logger.error(
f"[dispatch_notification] Notifica {notification_log_id} FALLITA "
f"definitivamente dopo {current_attempt} tentativi: {error_msg}"
)
return {
"status": "failed",
"log_id": notification_log_id,
"channel": channel.name,
"attempts": current_attempt,
"error": error_msg,
}
+718
View File
@@ -0,0 +1,718 @@
"""
Indicizzazione full-text dei messaggi PEC.
Responsabilita':
1. Scarica gli allegati da MinIO
2. Estrae il testo in base al formato del file
3. Aggiorna la colonna extracted_text in attachments
4. Aggiorna la colonna search_vector in messages includendo il testo degli allegati
Formati supportati:
- PDF (.pdf) tramite pypdf
- Word (.docx, .doc) tramite python-docx
- Excel (.xlsx, .xls) tramite openpyxl
- PowerPoint(.pptx, .ppt) tramite python-pptx
- LibreOffice (.odt, .ods, .odp) tramite odfpy
- RTF (.rtf) tramite striprtf
- Testo (.txt, .csv, .xml, .html, .htm) testo grezzo
- Email (.eml, .msg) tramite stdlib email
- Firmati (.p7m) unwrap CMS poi estrae in base all'estensione interna
Viene chiamato alla fine di _save_message in sync.py, in modo non bloccante:
un'eccezione qui non interrompe la sincronizzazione del messaggio.
"""
import io
import logging
import re
import uuid
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# Dimensione massima del testo estratto per allegato (caratteri)
MAX_EXTRACTED_TEXT_LEN = 50_000
# Dimensione massima del testo aggregato degli allegati per il search_vector
MAX_COMBINED_TEXT_LEN = 200_000
# ─── Rilevamento tipo file ────────────────────────────────────────────────────
def _ext(filename: str | None) -> str:
"""Restituisce l'estensione del file in minuscolo, senza punto."""
if not filename:
return ""
fn = filename.lower()
# Gestione doppia estensione es. documento.pdf.p7m
if fn.endswith(".p7m"):
return "p7m"
idx = fn.rfind(".")
return fn[idx + 1:] if idx >= 0 else ""
def _is_extractable(content_type: str | None, filename: str | None) -> bool:
"""Ritorna True se il formato e' supportato dall'estrattore."""
ct = (content_type or "").lower()
e = _ext(filename)
return e in _EXTRACTORS or ct in _CONTENT_TYPE_MAP
def _resolve_extractor(content_type: str | None, filename: str | None):
"""Ritorna la funzione estrattore appropriata, o None."""
e = _ext(filename)
if e in _EXTRACTORS:
return _EXTRACTORS[e]
ct = (content_type or "").lower()
if ct in _CONTENT_TYPE_MAP:
return _EXTRACTORS.get(_CONTENT_TYPE_MAP[ct])
return None
# ─── Estrattori ───────────────────────────────────────────────────────────────
# Soglia minima di caratteri estratti da pypdf prima di ricorrere all'OCR.
# Un PDF di testo reale produce migliaia di caratteri; una scansione ne produce
# zero o pochissimi (artefatti). 50 char e' un valore conservativo sicuro.
_PDF_OCR_THRESHOLD = 50
# Numero massimo di pagine su cui eseguire OCR per evitare timeout su PDF lunghi.
_PDF_OCR_MAX_PAGES = 15
def _extract_pdf(content: bytes) -> str:
"""
Estrae testo da PDF tramite pypdf.
Se il testo estratto e' inferiore a _PDF_OCR_THRESHOLD caratteri (PDF
image-only / scansione), attiva il fallback OCR via Tesseract.
"""
try:
import pypdf # type: ignore[import]
reader = pypdf.PdfReader(io.BytesIO(content))
parts: list[str] = []
for page in reader.pages:
try:
t = page.extract_text()
if t:
parts.append(t)
except Exception:
continue
text = " ".join(parts)
except ImportError:
logger.warning("pypdf non installato: impossibile estrarre testo da PDF")
return ""
except Exception as e:
logger.debug(f"Errore estrazione PDF: {e}")
return ""
# Se il testo e' troppo corto, il PDF e' probabilmente una scansione
if len(text.strip()) < _PDF_OCR_THRESHOLD:
logger.debug(
f"PDF con testo insufficiente ({len(text.strip())} char), "
"tentativo OCR..."
)
ocr_text = _extract_pdf_ocr(content)
if len(ocr_text.strip()) > len(text.strip()):
return ocr_text
return text
def _extract_pdf_ocr(content: bytes) -> str:
"""
OCR su PDF image-only tramite pdf2image + Tesseract.
Converte le pagine del PDF in immagini PIL a 200 DPI (buon compromesso
qualita'/velocita' su CPU) e applica Tesseract con lingua italiana + inglese.
Processa al massimo _PDF_OCR_MAX_PAGES pagine per evitare timeout.
"""
try:
from pdf2image import convert_from_bytes # type: ignore[import]
import pytesseract # type: ignore[import]
pages = convert_from_bytes(
content,
dpi=200,
last_page=_PDF_OCR_MAX_PAGES,
)
parts: list[str] = []
for page_img in pages:
try:
t = pytesseract.image_to_string(page_img, lang="ita+eng")
if t and t.strip():
parts.append(t.strip())
except Exception:
continue
return " ".join(parts)
except ImportError:
logger.warning(
"pdf2image o pytesseract non installati: impossibile OCR PDF"
)
return ""
except Exception as e:
logger.debug(f"Errore OCR PDF: {e}")
return ""
def _extract_image_ocr(content: bytes) -> str:
"""
Estrae testo da un file immagine (PNG, JPEG, TIFF, BMP, ecc.) tramite OCR.
Usa Tesseract con lingua italiana + inglese per massima copertura
su documenti italiani.
"""
try:
import pytesseract # type: ignore[import]
from PIL import Image # type: ignore[import]
img = Image.open(io.BytesIO(content))
# Converti in RGB se necessario (TIFF multi-frame, palette, ecc.)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
text = pytesseract.image_to_string(img, lang="ita+eng")
return " ".join(text.split())
except ImportError:
logger.warning(
"pytesseract o Pillow non installati: impossibile OCR immagine"
)
return ""
except Exception as e:
logger.debug(f"Errore OCR immagine: {e}")
return ""
def _extract_docx(content: bytes) -> str:
"""Estrae testo da DOCX/DOC tramite python-docx."""
try:
import docx # type: ignore[import]
doc = docx.Document(io.BytesIO(content))
parts = [p.text for p in doc.paragraphs if p.text and p.text.strip()]
# Include anche le tabelle
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
if cell.text and cell.text.strip():
parts.append(cell.text.strip())
return " ".join(parts)
except ImportError:
logger.warning("python-docx non installato: impossibile estrarre testo da DOCX")
return ""
except Exception as e:
logger.debug(f"Errore estrazione DOCX: {e}")
return ""
def _extract_xlsx(content: bytes) -> str:
"""Estrae testo da XLSX/XLS tramite openpyxl."""
try:
import openpyxl # type: ignore[import]
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
parts: list[str] = []
for ws in wb.worksheets:
for row in ws.iter_rows():
for cell in row:
if cell.value is not None:
v = str(cell.value).strip()
if v:
parts.append(v)
return " ".join(parts)
except ImportError:
logger.warning("openpyxl non installato: impossibile estrarre testo da XLSX")
return ""
except Exception as e:
logger.debug(f"Errore estrazione XLSX: {e}")
return ""
def _extract_pptx(content: bytes) -> str:
"""Estrae testo da PPTX/PPT tramite python-pptx."""
try:
from pptx import Presentation # type: ignore[import]
prs = Presentation(io.BytesIO(content))
parts: list[str] = []
for slide in prs.slides:
for shape in slide.shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
t = para.text.strip()
if t:
parts.append(t)
return " ".join(parts)
except ImportError:
logger.warning("python-pptx non installato: impossibile estrarre testo da PPTX")
return ""
except Exception as e:
logger.debug(f"Errore estrazione PPTX: {e}")
return ""
def _extract_odt(content: bytes) -> str:
"""Estrae testo da ODT/ODS/ODP tramite odfpy."""
try:
from odf import opendocument, teletype # type: ignore[import]
from odf.text import P # type: ignore[import]
doc = opendocument.load(io.BytesIO(content))
parts: list[str] = []
for el in doc.body.getElementsByType(P):
t = teletype.extractText(el).strip()
if t:
parts.append(t)
return " ".join(parts)
except ImportError:
logger.warning("odfpy non installato: impossibile estrarre testo da ODT")
return ""
except Exception as e:
logger.debug(f"Errore estrazione ODT: {e}")
return ""
def _extract_rtf(content: bytes) -> str:
"""Estrae testo da RTF tramite striprtf."""
try:
from striprtf.striprtf import rtf_to_text # type: ignore[import]
raw = content.decode("latin-1", errors="replace")
return rtf_to_text(raw)
except ImportError:
logger.warning("striprtf non installato: impossibile estrarre testo da RTF")
return ""
except Exception as e:
logger.debug(f"Errore estrazione RTF: {e}")
return ""
def _extract_plain(content: bytes) -> str:
"""Estrae testo da file di testo puro (txt, csv, xml, html, ecc.)."""
try:
# Prova UTF-8 prima, poi latin-1 come fallback
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
text = content.decode("latin-1", errors="replace")
# Per XML/HTML: rimuove i tag
if "<" in text and ">" in text:
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"&[a-zA-Z]+;", " ", text)
return " ".join(text.split())
except Exception as e:
logger.debug(f"Errore estrazione testo plain: {e}")
return ""
def _extract_eml(content: bytes) -> str:
"""Estrae testo da un file EML allegato."""
try:
import email as emaillib
msg = emaillib.message_from_bytes(content)
parts: list[str] = []
subject = msg.get("Subject", "")
if subject:
parts.append(subject)
sender = msg.get("From", "")
if sender:
parts.append(sender)
# Estrae body
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
try:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
parts.append(payload.decode(charset, errors="replace"))
except Exception:
pass
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
parts.append(payload.decode(charset, errors="replace")) # type: ignore[arg-type]
return " ".join(parts)
except Exception as e:
logger.debug(f"Errore estrazione EML: {e}")
return ""
def _extract_p7m(content: bytes, original_filename: str | None = None) -> str:
"""
Estrae testo da un documento con firma digitale CAdES (.p7m).
Prova a fare l'unwrap del CMS envelope tramite la libreria cryptography
(gia' presente nel worker). Se l'unwrap ha successo, determina il formato
del documento interno dall'estensione del nome originale (es. fattura.pdf.p7m
-> PDF) e applica l'estrattore appropriato.
"""
inner_content: bytes | None = None
# Metodo 1: cryptography (CMS/PKCS7)
try:
from cryptography.hazmat.primitives.serialization import pkcs7 # type: ignore[import]
# load_pem_pkcs7_certificates / load_der_pkcs7_certificates non espongono il payload
# Usiamo il modulo backend direttamente
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
from cryptography.x509 import load_der_x509_certificate # noqa: F401
# Prova parsing DER diretto della struttura CMS ContentInfo
# La struttura ASN.1 di SignedData contiene encapContentInfo -> eContent
from cryptography.hazmat.bindings._rust import ( # type: ignore[import]
x509 as rust_x509,
)
_ = rust_x509 # solo per verificare import
except Exception:
pass
# Metodo piu' semplice: parsing ASN.1 manuale per estrarre eContent
# La struttura DER di CMS SignedData:
# SEQUENCE {
# OID (signedData)
# [0] EXPLICIT SEQUENCE {
# INTEGER (version)
# SET (digestAlgorithms)
# SEQUENCE (encapContentInfo) {
# OID (contentType = data)
# [0] EXPLICIT OCTET STRING (eContent) <- questo e' il contenuto originale
# }
# ...
# }
# }
try:
inner_content = _unwrap_p7m_asn1(content)
except Exception as e:
logger.debug(f"Unwrap P7M ASN1 fallito: {e}")
if not inner_content:
logger.debug("Impossibile estrarre contenuto dal file .p7m")
return ""
# Determina l'estensione interna dal nome file originale
# es. "fattura.pdf.p7m" -> inner ext = "pdf"
inner_ext = ""
if original_filename:
fn = original_filename.lower()
if fn.endswith(".p7m"):
fn = fn[:-4] # rimuove .p7m
idx = fn.rfind(".")
if idx >= 0:
inner_ext = fn[idx + 1:]
extractor = _EXTRACTORS.get(inner_ext)
if extractor:
logger.debug(f"P7M: estrazione interna come {inner_ext!r}")
return extractor(inner_content)
# Fallback: prova a riconoscere il formato dall'header del contenuto
if inner_content[:4] == b"%PDF":
return _extract_pdf(inner_content)
if inner_content[:2] in (b"PK",): # ZIP-based (docx, xlsx, pptx, odt)
# Prova nell'ordine piu' comune
for fn in (_extract_docx, _extract_xlsx, _extract_pptx, _extract_odt):
result = fn(inner_content)
if result.strip():
return result
# Ultimo tentativo: plain text
return _extract_plain(inner_content)
def _unwrap_p7m_asn1(data: bytes) -> bytes | None:
"""
Parsing ASN.1 DER minimale per estrarre eContent da una struttura CMS SignedData.
Non verifica la firma: serve solo per l'estrazione del testo.
"""
pos = 0
length = len(data)
def read_tag_length(buf: bytes, offset: int) -> tuple[int, int, int]:
"""Ritorna (tag, length, new_offset)."""
tag = buf[offset]
offset += 1
lb = buf[offset]
offset += 1
if lb & 0x80:
num_bytes = lb & 0x7F
ln = int.from_bytes(buf[offset:offset + num_bytes], "big")
offset += num_bytes
else:
ln = lb
return tag, ln, offset
# outer SEQUENCE
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x30:
return None
# OID (contentType = signedData)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x06:
return None
pos += ln # skip OID value
# [0] EXPLICIT
tag, ln, pos = read_tag_length(data, pos)
if tag != 0xA0:
return None
# SEQUENCE (SignedData)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x30:
return None
# INTEGER (version)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x02:
return None
pos += ln
# SET (digestAlgorithms)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x31:
return None
pos += ln
# SEQUENCE (encapContentInfo)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x30:
return None
# OID (contentType dentro encapContentInfo)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x06:
return None
pos += ln
# [0] EXPLICIT (eContent, opzionale)
tag, ln, pos = read_tag_length(data, pos)
if tag != 0xA0:
return None
# OCTET STRING con il contenuto originale
tag, ln, pos = read_tag_length(data, pos)
if tag != 0x04:
return None
return data[pos: pos + ln]
# ─── Mapping formato -> estrattore ────────────────────────────────────────────
_EXTRACTORS: dict[str, object] = {
# Documenti Office
"pdf": _extract_pdf,
"docx": _extract_docx,
"doc": _extract_docx,
"xlsx": _extract_xlsx,
"xls": _extract_xlsx,
"pptx": _extract_pptx,
"ppt": _extract_pptx,
# LibreOffice
"odt": _extract_odt,
"ods": _extract_odt,
"odp": _extract_odt,
# Testo
"txt": _extract_plain,
"csv": _extract_plain,
"xml": _extract_plain,
"html": _extract_plain,
"htm": _extract_plain,
"json": _extract_plain,
# RTF
"rtf": _extract_rtf,
# Email
"eml": _extract_eml,
"msg": _extract_eml,
# Firma digitale CAdES
"p7m": _extract_p7m,
# Immagini (OCR)
"png": _extract_image_ocr,
"jpg": _extract_image_ocr,
"jpeg": _extract_image_ocr,
"tiff": _extract_image_ocr,
"tif": _extract_image_ocr,
"bmp": _extract_image_ocr,
"gif": _extract_image_ocr,
"webp": _extract_image_ocr,
}
# Mapping content-type -> estensione normalizzata (per fallback quando il filename manca)
_CONTENT_TYPE_MAP: dict[str, str] = {
"application/pdf": "pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/msword": "doc",
"application/vnd.ms-word": "doc",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/vnd.ms-excel": "xls",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
"application/vnd.ms-powerpoint": "ppt",
"application/vnd.oasis.opendocument.text": "odt",
"application/vnd.oasis.opendocument.spreadsheet": "ods",
"application/vnd.oasis.opendocument.presentation": "odp",
"application/rtf": "rtf",
"text/rtf": "rtf",
"text/plain": "txt",
"text/csv": "csv",
"text/xml": "xml",
"application/xml": "xml",
"text/html": "html",
"message/rfc822": "eml",
"application/pkcs7-mime": "p7m",
"application/x-pkcs7-mime": "p7m",
# Immagini (OCR)
"image/png": "png",
"image/jpeg": "jpeg",
"image/jpg": "jpeg",
"image/tiff": "tiff",
"image/bmp": "bmp",
"image/gif": "gif",
"image/webp": "webp",
}
# ─── Job principale ───────────────────────────────────────────────────────────
async def index_message(
message_id: uuid.UUID,
db: AsyncSession,
) -> None:
"""
Indicizza un messaggio per la ricerca full-text.
Non solleva eccezioni: tutti gli errori vengono loggati ma non propagati,
per non interrompere il flusso di sincronizzazione.
"""
try:
await _do_index_message(message_id, db)
except Exception as e:
logger.error(
f"Errore indicizzazione messaggio {message_id}: {e}",
exc_info=True,
)
async def _do_index_message(
message_id: uuid.UUID,
db: AsyncSession,
) -> None:
"""Logica interna di indicizzazione (puo' sollevare eccezioni)."""
from app.config import get_settings
from app.models import Attachment, Message
settings = get_settings()
# ── Carica il messaggio ───────────────────────────────────────────────────
msg_result = await db.execute(
select(Message).where(Message.id == message_id)
)
message = msg_result.scalar_one_or_none()
if not message:
logger.warning(f"index_message: messaggio {message_id} non trovato in DB")
return
# ── Carica gli allegati ───────────────────────────────────────────────────
att_result = await db.execute(
select(Attachment).where(Attachment.message_id == message_id)
)
attachments = list(att_result.scalars().all())
if not attachments:
logger.debug(f"Messaggio {message_id}: nessun allegato, skip indicizzazione allegati")
return
# ── Crea client MinIO ─────────────────────────────────────────────────────
try:
from miniopy_async import Minio # type: ignore[import]
minio = Minio(
endpoint=settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_use_ssl,
)
except Exception as e:
logger.warning(f"Impossibile creare client MinIO per indicizzazione {message_id}: {e}")
return
bucket = settings.minio_bucket
attachment_texts: list[str] = []
indexed_count = 0
for att in attachments:
# Se gia' indicizzato, usa il testo cached
if att.extracted_text is not None:
attachment_texts.append(att.extracted_text)
continue
# Controlla se il formato e' supportato
extractor = _resolve_extractor(att.content_type, att.filename)
if extractor is None:
logger.debug(
f"Formato non supportato per indicizzazione: "
f"{att.filename!r} ({att.content_type!r})"
)
continue
# Scarica da MinIO
try:
response = await minio.get_object(bucket, att.storage_path)
content = await response.content.read()
response.close()
except Exception as e:
logger.warning(
f"Impossibile scaricare allegato {att.id} "
f"({att.filename!r}) da MinIO: {e}"
)
continue
# Estrai testo - per p7m passa anche il filename originale
try:
e = _ext(att.filename)
if e == "p7m":
extracted = _extract_p7m(content, att.filename)
else:
extracted = extractor(content) # type: ignore[operator]
except Exception as ex:
logger.debug(f"Errore estrazione {att.filename!r}: {ex}")
continue
if not extracted or not extracted.strip():
logger.debug(f"Nessun testo estratto da {att.filename!r}")
continue
# Limita la dimensione e salva sull'ORM object (col. mappata)
att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN]
attachment_texts.append(att.extracted_text)
indexed_count += 1
logger.debug(
f"Testo estratto da {att.filename!r}: "
f"{len(att.extracted_text)} caratteri"
)
# ── Aggiorna search_vector includendo il testo degli allegati ─────────────
if attachment_texts:
combined = " ".join(attachment_texts)[:MAX_COMBINED_TEXT_LEN]
await db.execute(
text("""
UPDATE messages
SET search_vector =
setweight(to_tsvector('italian', coalesce(subject, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') ||
setweight(to_tsvector('simple',
coalesce(array_to_string(to_addresses, ' '), '')), 'B') ||
setweight(to_tsvector('italian', coalesce(body_text, '')), 'C') ||
setweight(to_tsvector('italian', :att_text), 'D')
WHERE id = :message_id
"""),
{"att_text": combined, "message_id": str(message_id)},
)
await db.flush()
logger.info(
f"Indicizzazione completata: messaggio {message_id}, "
f"{indexed_count} allegati indicizzati su {len(attachments)} totali"
)
else:
logger.debug(
f"Messaggio {message_id}: nessun allegato con testo estraibile"
)
+3 -1
View File
@@ -24,6 +24,8 @@ from arq.connections import RedisSettings
from app.config import get_settings
from app.imap.pool import MailboxPool
from app.jobs.apply_routing_rules import apply_routing_rules
from app.jobs.dispatch_notification import dispatch_notification
from app.jobs.send_pec import send_pec
from app.jobs.sync_mailbox import sync_mailbox
from app.smtp.receipt_watcher import watch_receipt
@@ -132,7 +134,7 @@ class WorkerSettings:
"""Configurazione del worker arq."""
# Funzioni/job registrati
functions = [sync_mailbox, send_pec, watch_receipt, health_check]
functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, apply_routing_rules, health_check]
# Callbacks lifecycle
on_startup = on_startup
+127 -1
View File
@@ -22,7 +22,7 @@ from sqlalchemy import (
ARRAY, BigInteger, Boolean, DateTime, Enum, ForeignKey,
Integer, String, Text, func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -194,9 +194,135 @@ class Attachment(Base):
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)
# Testo estratto dall'indicizzatore full-text per la ricerca
extracted_text: Mapped[str | None] = mapped_column(Text, 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")
# ─── Modelli Notifiche ────────────────────────────────────────────────────────
NotifChannelType = Enum(
"webhook", "email", "telegram", "whatsapp",
name="notification_channel_type",
create_type=False,
)
NotifStatus = Enum(
"pending", "sent", "failed", "skipped",
name="notification_status",
create_type=False,
)
class NotificationChannel(Base):
"""
Canale di notifica configurato da un tenant.
Corrisponde alla tabella `notification_channels` nel DB.
"""
__tablename__ = "notification_channels"
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)
name: Mapped[str] = mapped_column(String(255), nullable=False)
channel_type: Mapped[str] = mapped_column(NotifChannelType, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
config_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
circuit_open_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
rules: Mapped[list["NotificationRule"]] = relationship(
"NotificationRule", back_populates="channel", lazy="select"
)
logs: Mapped[list["NotificationLog"]] = relationship(
"NotificationLog", back_populates="channel", lazy="select"
)
class NotificationRule(Base):
"""
Regola evento PEC -> canale di notifica.
Corrisponde alla tabella `notification_rules` nel DB.
"""
__tablename__ = "notification_rules"
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)
channel_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_channels.id", ondelete="CASCADE"),
nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
filter: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
channel: Mapped["NotificationChannel"] = relationship(
"NotificationChannel", back_populates="rules"
)
class NotificationLog(Base):
"""
Log di ogni tentativo di notifica con retry e circuit breaker.
Corrisponde alla tabella `notification_log` nel DB.
"""
__tablename__ = "notification_log"
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)
channel_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_channels.id", ondelete="CASCADE"),
nullable=False,
)
rule_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_rules.id", ondelete="SET NULL"),
nullable=True,
)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
event_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
status: Mapped[str] = mapped_column(NotifStatus, nullable=False, default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
next_retry_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
channel: Mapped["NotificationChannel"] = relationship(
"NotificationChannel", back_populates="logs"
)
+1
View File
@@ -0,0 +1 @@
# Senders notifiche multi-canale per il worker
+76
View File
@@ -0,0 +1,76 @@
"""
Email SMTP sender (worker) invio notifiche via aiosmtplib.
Copia del sender backend: i due container sono separati.
"""
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
DEFAULT_TIMEOUT = 15.0
class EmailSMTPError(Exception):
def __init__(self, message: str, smtp_code: int | None = None):
super().__init__(message)
self.smtp_code = smtp_code
async def send_email_notification(
smtp_host: str,
smtp_port: int,
smtp_user: str,
smtp_password: str,
from_email: str,
to_email: str,
subject: str,
body_text: str,
body_html: str | None = None,
from_name: str = "PEChub Notifiche",
use_tls: bool = True,
use_starttls: bool = False,
timeout: float = DEFAULT_TIMEOUT,
) -> None:
"""
Invia un'email di notifica via SMTP.
Raises:
EmailSMTPError: in caso di errori di autenticazione, connessione o invio
"""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
msg["To"] = to_email
msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
msg["X-Mailer"] = "PEChub/1.0"
msg.attach(MIMEText(body_text, "plain", "utf-8"))
if body_html:
msg.attach(MIMEText(body_html, "html", "utf-8"))
try:
await aiosmtplib.send(
msg,
hostname=smtp_host,
port=smtp_port,
username=smtp_user,
password=smtp_password,
use_tls=use_tls,
start_tls=use_starttls,
timeout=timeout,
)
except aiosmtplib.SMTPAuthenticationError as exc:
raise EmailSMTPError(
f"Autenticazione SMTP fallita: {exc}", smtp_code=535
) from exc
except aiosmtplib.SMTPConnectError as exc:
raise EmailSMTPError(
f"Connessione SMTP fallita a {smtp_host}:{smtp_port}: {exc}"
) from exc
except aiosmtplib.SMTPException as exc:
raise EmailSMTPError(f"Errore SMTP: {exc}") from exc
except Exception as exc:
raise EmailSMTPError(f"Errore invio email: {exc}") from exc
+69
View File
@@ -0,0 +1,69 @@
"""
Telegram Bot API invio messaggi via sendMessage (worker).
Copia del sender backend: i due container sono separati e non
possono condividere package, quindi il codice e' duplicato.
"""
import httpx
TELEGRAM_API_BASE = "https://api.telegram.org"
DEFAULT_TIMEOUT = 10.0
class TelegramError(Exception):
def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
super().__init__(message)
self.http_status = http_status
self.api_code = api_code
async def send_message(
bot_token: str,
chat_id: str,
text: str,
parse_mode: str = "HTML",
disable_web_page_preview: bool = True,
timeout: float = DEFAULT_TIMEOUT,
) -> dict:
"""
Invia un messaggio a un canale/gruppo/utente Telegram.
Returns:
dict con il risultato della API Telegram (result.message_id, ecc.)
Raises:
TelegramError: in caso di errore HTTP o risposta API non-ok
"""
url = f"{TELEGRAM_API_BASE}/bot{bot_token}/sendMessage"
payload: dict = {"chat_id": chat_id, "text": text}
if parse_mode:
payload["parse_mode"] = parse_mode
if disable_web_page_preview:
payload["link_preview_options"] = {"is_disabled": True}
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(url, json=payload)
except httpx.TimeoutException as exc:
raise TelegramError(f"Timeout Telegram ({timeout}s)") from exc
except httpx.RequestError as exc:
raise TelegramError(f"Errore di rete Telegram: {exc}") from exc
if response.status_code != 200:
raise TelegramError(
f"Telegram API HTTP {response.status_code}: {response.text[:200]}",
http_status=response.status_code,
)
data = response.json()
if not data.get("ok"):
api_code = data.get("error_code")
description = data.get("description", "Errore sconosciuto")
raise TelegramError(
f"Telegram API error {api_code}: {description}",
http_status=response.status_code,
api_code=api_code,
)
return data.get("result", {})
+74
View File
@@ -0,0 +1,74 @@
"""
Webhook sender (worker) POST HTTP con firma HMAC-SHA256.
Copia del sender backend: i due container sono separati.
"""
import hashlib
import hmac
import json
import uuid as uuid_mod
import httpx
DEFAULT_TIMEOUT = 10.0
class WebhookError(Exception):
def __init__(self, message: str, http_status: int | None = None):
super().__init__(message)
self.http_status = http_status
async def send_webhook(
url: str,
payload: dict,
event_type: str = "new_message",
webhook_secret: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
) -> dict:
"""
Invia un payload JSON a un webhook URL.
Returns:
dict con http_status, delivery_id
Raises:
WebhookError: in caso di timeout, errore di rete o HTTP >= 400
"""
body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
delivery_id = str(uuid_mod.uuid4())
headers = {
"Content-Type": "application/json",
"X-PEChub-Event": event_type,
"X-Delivery": delivery_id,
"User-Agent": "PEChub-Webhook/1.0",
}
if webhook_secret:
sig = hmac.new(
webhook_secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
headers["X-Hub-Signature-256"] = f"sha256={sig}"
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(url, content=body, headers=headers)
except httpx.TimeoutException as exc:
raise WebhookError(f"Timeout webhook dopo {timeout}s") from exc
except httpx.RequestError as exc:
raise WebhookError(f"Errore di rete webhook: {exc}") from exc
if response.status_code >= 400:
raise WebhookError(
f"Webhook HTTP {response.status_code}: {response.text[:200]}",
http_status=response.status_code,
)
return {
"http_status": response.status_code,
"delivery_id": delivery_id,
}
+73
View File
@@ -0,0 +1,73 @@
"""
WhatsApp sender (worker) Meta Cloud API v18.
Copia del sender backend: i due container sono separati.
"""
import httpx
META_GRAPH_API_URL = "https://graph.facebook.com/v18.0"
DEFAULT_TIMEOUT = 10.0
class WhatsAppError(Exception):
def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
super().__init__(message)
self.http_status = http_status
self.api_code = api_code
async def send_whatsapp_message(
phone_number_id: str,
to_phone: str,
text: str,
access_token: str,
timeout: float = DEFAULT_TIMEOUT,
) -> dict:
"""
Invia un messaggio di testo WhatsApp via Meta Cloud API.
Raises:
WhatsAppError: in caso di errore HTTP o risposta API non-ok
"""
url = f"{META_GRAPH_API_URL}/{phone_number_id}/messages"
payload = {
"messaging_product": "whatsapp",
"to": to_phone.replace(" ", "").replace("-", ""),
"type": "text",
"text": {"body": text},
}
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(url, json=payload, headers=headers)
except httpx.TimeoutException as exc:
raise WhatsAppError(f"Timeout WhatsApp API dopo {timeout}s") from exc
except httpx.RequestError as exc:
raise WhatsAppError(f"Errore di rete WhatsApp: {exc}") from exc
if response.status_code == 401:
raise WhatsAppError("Token Meta non valido o scaduto", http_status=401)
if response.status_code >= 400:
try:
err_data = response.json()
err_msg = err_data.get("error", {}).get("message", response.text[:200])
err_code = err_data.get("error", {}).get("code")
except Exception:
err_msg = response.text[:200]
err_code = None
raise WhatsAppError(
f"Meta API errore HTTP {response.status_code}: {err_msg}",
http_status=response.status_code,
api_code=err_code,
)
data = response.json()
messages = data.get("messages", [])
message_id = messages[0].get("id") if messages else None
return {"message_id": message_id, "http_status": response.status_code}
+25 -14
View File
@@ -116,7 +116,7 @@ def parse_date(date_str: str | None) -> datetime | None:
# ─── Parser principale ────────────────────────────────────────────────────────
def parse_eml(raw_bytes: bytes) -> ParsedEmail:
def parse_eml(raw_bytes: bytes, is_receipt: bool = False) -> ParsedEmail:
"""
Parsing completo di un raw EML.
@@ -126,7 +126,11 @@ def parse_eml(raw_bytes: bytes) -> ParsedEmail:
- Allegati: tutti i parti con filename, inclusi message/rfc822
Args:
raw_bytes: byte del messaggio EML grezzo
raw_bytes: byte del messaggio EML grezzo
is_receipt: True se il messaggio e' una ricevuta PEC (accettazione,
avvenuta_consegna, ecc.). In questo caso il body_text/html
esterno (testo della ricevuta) non viene sovrascritto con
il contenuto del messaggio annidato in postacert.eml.
Returns:
ParsedEmail con tutti i campi estratti (fields None/[] se non presenti)
@@ -153,7 +157,7 @@ def parse_eml(raw_bytes: bytes) -> ParsedEmail:
# ── Body e allegati ───────────────────────────────────────────────────────
if msg.is_multipart():
_walk_parts(msg, result)
_walk_parts(msg, result, is_receipt=is_receipt)
else:
_extract_single_part_body(msg, result)
@@ -208,7 +212,7 @@ def _get_filename(part: email.message.Message) -> str | None:
return None
def _walk_parts(msg: email.message.Message, result: ParsedEmail) -> None:
def _walk_parts(msg: email.message.Message, result: ParsedEmail, is_receipt: bool = False) -> None:
"""
Naviga ricorsivamente tutti i part MIME del messaggio.
@@ -230,7 +234,7 @@ def _walk_parts(msg: email.message.Message, result: ParsedEmail) -> None:
# ── EML-in-EML (message/rfc822) ───────────────────────────────────────
if ct == "message/rfc822":
_extract_eml_in_eml(part, filename, result)
_extract_eml_in_eml(part, filename, result, is_receipt=is_receipt)
continue
# ── Allegato esplicito (Content-Disposition: attachment) ──────────────
@@ -292,12 +296,16 @@ def _extract_eml_in_eml(
part: email.message.Message,
filename: str | None,
result: ParsedEmail,
is_receipt: bool = False,
) -> None:
"""
Estrae il messaggio EML annidato in un part message/rfc822.
Per postacert.eml (busta PEC in arrivo): ricorre dentro per estrarre
Per postacert.eml in messaggi posta_certificata: ricorre dentro per estrarre
gli allegati utente e il corpo del messaggio originale del mittente.
Per le ricevute (is_receipt=True): estrae solo gli allegati utente senza
sovrascrivere il body gia' impostato (che e' il testo della ricevuta stessa).
"""
try:
payload = part.get_payload()
@@ -305,7 +313,7 @@ def _extract_eml_in_eml(
inner_bytes: bytes | None = None
if isinstance(payload, list) and payload:
# Forma canonica: payload è lista di Message
# Forma canonica: payload e' lista di Message
inner_msg = payload[0]
if isinstance(inner_msg, email.message.Message):
inner_bytes = inner_msg.as_bytes()
@@ -330,19 +338,22 @@ def _extract_eml_in_eml(
)
result.attachments.append(att)
# Per postacert.eml: ricorre dentro per trovare allegati utente e corpo originale
# Per postacert.eml: ricorre dentro per trovare allegati utente
if is_system and eff_filename.lower() == "postacert.eml":
inner_parsed = parse_eml(inner_bytes)
# Allegati non-sistema del messaggio originale del mittente
for inner_att in inner_parsed.attachments:
if not inner_att.is_pec_system:
result.attachments.append(inner_att)
# Corpo del messaggio originale (più utile del testo della busta PEC)
if inner_parsed.body_html:
result.body_html = inner_parsed.body_html
result.body_text = inner_parsed.body_text
elif inner_parsed.body_text:
result.body_text = inner_parsed.body_text
# Sovrascrive il corpo SOLO per messaggi posta_certificata (non ricevute).
# Per le ricevute il body esterno e' gia' il testo corretto della ricevuta;
# postacert.eml contiene il messaggio originale inviato che non va mostrato.
if not is_receipt:
if inner_parsed.body_html:
result.body_html = inner_parsed.body_html
result.body_text = inner_parsed.body_text
elif inner_parsed.body_text:
result.body_text = inner_parsed.body_text
except Exception as exc:
logger.warning(f"Errore estrazione EML-in-EML: {exc}")
+13
View File
@@ -41,6 +41,19 @@ dependencies = [
# Utilities
"python-dotenv>=1.0.0",
"email-validator>=2.2.0",
# Full-text search: estrazione testo da allegati
"pypdf>=4.0.0",
"python-docx>=1.1.0",
"openpyxl>=3.1.0",
"python-pptx>=1.0.0",
"odfpy>=1.4.1",
"striprtf>=0.0.26",
# OCR per allegati image-only (immagini dirette e PDF scansionati)
"pytesseract>=0.3.13",
"pdf2image>=1.17.0",
"Pillow>=11.0.0",
]
[project.optional-dependencies]
+129
View File
@@ -0,0 +1,129 @@
"""
Script one-shot: corregge il body_text/body_html delle ricevute PEC gia' in DB.
Problema: il parser EML sovrascriveva il body delle ricevute con il contenuto
di postacert.eml (messaggio originale inviato), invece di mostrare il testo
della ricevuta stessa.
Questo script:
1. Trova tutti i messaggi in DB con pec_type di tipo ricevuta
2. Scarica l'EML grezzo da MinIO (raw_eml_path)
3. Lo ri-parsa con is_receipt=True (parser corretto)
4. Aggiorna body_text e body_html nel DB
Uso:
cd /opt/pechub
docker compose exec pechub-worker-1 python /app/scripts/fix_receipt_body.py
"""
import asyncio
import logging
import sys
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
# Aggiungi il path dell'app
sys.path.insert(0, "/app")
from app.config import get_settings
from app.models import Message
from app.parsers.eml_parser import parse_eml
from app.storage.minio_client import download_attachment
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger(__name__)
# Tipi di ricevuta che potrebbero avere il body sbagliato
RECEIPT_TYPES = {
"accettazione",
"non_accettazione",
"presa_in_carico",
"avvenuta_consegna",
"mancata_consegna",
"errore_consegna",
"preavviso_mancata_consegna",
"rilevazione_virus",
}
async def fix_receipt_bodies() -> None:
settings = get_settings()
engine = create_async_engine(settings.database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
# Trova tutti i messaggi ricevuta con raw_eml_path
result = await db.execute(
select(Message).where(
Message.pec_type.in_(RECEIPT_TYPES),
Message.raw_eml_path.is_not(None),
).order_by(Message.created_at)
)
messages = result.scalars().all()
logger.info(f"Trovate {len(messages)} ricevute da verificare")
fixed = 0
skipped = 0
errors = 0
for msg in messages:
try:
# Scarica EML grezzo da MinIO (download_attachment funziona per qualsiasi path)
raw_eml = await download_attachment(msg.raw_eml_path)
if not raw_eml:
logger.warning(f"EML non trovato su MinIO per messaggio {msg.id} (path={msg.raw_eml_path!r})")
skipped += 1
continue
# Re-parsing con is_receipt=True (parser corretto)
parsed = parse_eml(raw_eml, is_receipt=True)
# Controlla se il body e' cambiato
new_body_text = parsed.body_text
new_body_html = parsed.body_html
if new_body_text == msg.body_text and new_body_html == msg.body_html:
logger.debug(f"Messaggio {msg.id} ({msg.pec_type}): body invariato, skip")
skipped += 1
continue
# Aggiorna nel DB
msg.body_text = new_body_text
msg.body_html = new_body_html
msg.updated_at = datetime.now(UTC)
logger.info(
f"Fixato: id={msg.id} pec_type={msg.pec_type!r} subject={msg.subject!r} "
f"body_text_len={len(new_body_text or '')}"
)
fixed += 1
except Exception as e:
logger.error(f"Errore su messaggio {msg.id}: {e}", exc_info=True)
errors += 1
continue
if fixed > 0:
await db.commit()
logger.info(f"Commit eseguito: {fixed} messaggi aggiornati")
else:
logger.info("Nessun messaggio da aggiornare")
logger.info(
f"Completato: fixed={fixed} skipped={skipped} errors={errors} "
f"totale={len(messages)}"
)
await engine.dispose()
if __name__ == "__main__":
asyncio.run(fix_receipt_bodies())