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