mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Implementazioni varie
This commit is contained in:
@@ -659,6 +659,32 @@ async def _save_message(
|
||||
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}")
|
||||
+2
-1
@@ -24,6 +24,7 @@ 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
|
||||
@@ -133,7 +134,7 @@ class WorkerSettings:
|
||||
"""Configurazione del worker arq."""
|
||||
|
||||
# Funzioni/job registrati
|
||||
functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, health_check]
|
||||
functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, apply_routing_rules, health_check]
|
||||
|
||||
# Callbacks lifecycle
|
||||
on_startup = on_startup
|
||||
|
||||
Reference in New Issue
Block a user