""" 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}")