mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Merge branch 'main' of https://github.com/idrainformatica/PecFlow
This commit is contained in:
+55
-5
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Senders notifiche multi-canale per il worker
|
||||
@@ -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
|
||||
@@ -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", {})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user