""" Service per la gestione delle regole di smistamento automatico (Feature 2). Il metodo evaluate_rules() viene chiamato dal worker dopo ogni messaggio inbound. """ import re import uuid from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.exceptions import NotFoundError from app.models.label import Label, MessageLabel from app.models.message import Message from app.models.routing_rule import RoutingRule, RoutingRuleAction, RoutingRuleCondition from app.schemas.routing_rule import RoutingRuleCreate, RoutingRuleUpdate class RoutingRuleService: def __init__(self, db: AsyncSession): self.db = db # ─── CRUD ───────────────────────────────────────────────────────────────── async def list_rules( self, tenant_id: uuid.UUID, ) -> tuple[list[RoutingRule], int]: query = ( select(RoutingRule) .where(RoutingRule.tenant_id == tenant_id) .options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions)) .order_by(RoutingRule.priority) ) count_q = select(func.count()).select_from( select(RoutingRule).where(RoutingRule.tenant_id == tenant_id).subquery() ) total = (await self.db.execute(count_q)).scalar_one() items = list((await self.db.execute(query)).scalars().all()) return items, total async def get_rule( self, tenant_id: uuid.UUID, rule_id: uuid.UUID ) -> RoutingRule: result = await self.db.execute( select(RoutingRule) .where(RoutingRule.id == rule_id, RoutingRule.tenant_id == tenant_id) .options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions)) ) rule = result.scalar_one_or_none() if not rule: raise NotFoundError(f"Regola {rule_id} non trovata") return rule async def create_rule( self, tenant_id: uuid.UUID, data: RoutingRuleCreate, created_by: uuid.UUID | None = None, ) -> RoutingRule: rule = RoutingRule( tenant_id=tenant_id, name=data.name, description=data.description, is_active=data.is_active, priority=data.priority, stop_processing=data.stop_processing, created_by=created_by, ) self.db.add(rule) await self.db.flush() for cond in data.conditions: self.db.add(RoutingRuleCondition( rule_id=rule.id, field=cond.field, operator=cond.operator, value=cond.value, )) for action in data.actions: self.db.add(RoutingRuleAction( rule_id=rule.id, action_type=action.action_type, action_value=action.action_value, )) await self.db.commit() return await self.get_rule(tenant_id, rule.id) async def update_rule( self, tenant_id: uuid.UUID, rule_id: uuid.UUID, data: RoutingRuleUpdate, ) -> RoutingRule: rule = await self.get_rule(tenant_id, rule_id) if data.name is not None: rule.name = data.name if data.description is not None: rule.description = data.description if data.is_active is not None: rule.is_active = data.is_active if data.priority is not None: rule.priority = data.priority if data.stop_processing is not None: rule.stop_processing = data.stop_processing # Se condizioni o azioni vengono aggiornate, le sostituisce completamente if data.conditions is not None: await self.db.execute( delete(RoutingRuleCondition).where(RoutingRuleCondition.rule_id == rule_id) ) for cond in data.conditions: self.db.add(RoutingRuleCondition( rule_id=rule_id, field=cond.field, operator=cond.operator, value=cond.value, )) if data.actions is not None: await self.db.execute( delete(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule_id) ) for action in data.actions: self.db.add(RoutingRuleAction( rule_id=rule_id, action_type=action.action_type, action_value=action.action_value, )) await self.db.commit() return await self.get_rule(tenant_id, rule_id) async def delete_rule( self, tenant_id: uuid.UUID, rule_id: uuid.UUID ) -> None: rule = await self.get_rule(tenant_id, rule_id) await self.db.delete(rule) await self.db.commit() # ─── Motore di valutazione ──────────────────────────────────────────────── async def evaluate_and_apply( self, message: Message, ) -> int: """ Valuta le regole attive del tenant e applica le azioni su message. Returns: Numero di regole che hanno prodotto match. """ # Carica regole attive ordinate per priority result = await self.db.execute( select(RoutingRule) .where( RoutingRule.tenant_id == message.tenant_id, RoutingRule.is_active == True, # noqa: E712 ) .options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions)) .order_by(RoutingRule.priority) ) rules = list(result.scalars().all()) matched_count = 0 for rule in rules: if await self._matches(message, rule.conditions): matched_count += 1 await self._apply_actions(message, rule.actions) if rule.stop_processing: break if matched_count > 0: await self.db.flush() return matched_count async def _matches( self, message: Message, conditions: list[RoutingRuleCondition], ) -> bool: """Restituisce True se tutte le condizioni (AND) sono soddisfatte.""" if not conditions: # Una regola senza condizioni non fa mai match (comportamento sicuro) return False for cond in conditions: field_value = self._get_field_value(message, cond.field) if not self._evaluate_condition(field_value, cond.operator, cond.value): return False return True def _get_field_value(self, message: Message, field: str) -> str: """Estrae il valore del campo dal messaggio come stringa per il confronto.""" if field == "from_address": return (message.from_address or "").lower() elif field == "to_address": return " ".join(message.to_addresses or []).lower() elif field == "subject": return (message.subject or "").lower() elif field == "mailbox_id": return str(message.mailbox_id) elif field == "pec_type": return message.pec_type or "" return "" def _evaluate_condition( self, 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 async def _apply_actions( self, message: Message, actions: list[RoutingRuleAction], ) -> None: """Esegue le azioni sulla regola che ha fatto match.""" for action in actions: try: if action.action_type == "apply_label" and action.action_value: await self._action_apply_label(message, uuid.UUID(action.action_value)) elif action.action_type == "mark_read": message.is_read = True elif action.action_type == "mark_starred": message.is_starred = True elif action.action_type == "notify_webhook" and action.action_value: await self._action_notify_webhook(message, action.action_value) except Exception: # Le azioni non devono interrompere il flusso principale pass async def _action_apply_label( self, message: Message, label_id: uuid.UUID ) -> None: """Applica un'etichetta al messaggio (se non gia' presente).""" # Verifica che la label appartenga al tenant label = await self.db.execute( select(Label).where( Label.id == label_id, Label.tenant_id == message.tenant_id, ) ) if not label.scalar_one_or_none(): return # Verifica che non sia gia' applicata existing = await self.db.execute( select(MessageLabel).where( MessageLabel.message_id == message.id, MessageLabel.label_id == label_id, ) ) if not existing.scalar_one_or_none(): self.db.add(MessageLabel(message_id=message.id, label_id=label_id)) async def _action_notify_webhook(self, message: Message, url: str) -> None: """Invia una notifica webhook per il messaggio.""" import aiohttp import json payload = { "event": "routing_rule_match", "message_id": str(message.id), "subject": message.subject, "from_address": message.from_address, "pec_type": message.pec_type, } try: async with aiohttp.ClientSession() as session: await session.post( url, json=payload, timeout=aiohttp.ClientTimeout(total=5), ) except Exception: pass