mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
235 lines
8.2 KiB
Python
235 lines
8.2 KiB
Python
"""
|
||
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}")
|