Implementazioni varie

This commit is contained in:
2026-03-27 20:59:06 +01:00
parent 047990811f
commit 46784aca4c
40 changed files with 4090 additions and 34 deletions
+240
View File
@@ -0,0 +1,240 @@
"""
Service per la gestione della rubrica indirizzi PEC (Feature 6).
"""
import csv
import io
import uuid
from sqlalchemy import func, or_, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.models.pec_contact import PecContact
from app.schemas.pec_contact import (
PecContactCreate,
PecContactImportResult,
PecContactUpdate,
)
class PecContactService:
def __init__(self, db: AsyncSession):
self.db = db
async def list_contacts(
self,
tenant_id: uuid.UUID,
q: str | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[PecContact], int]:
query = select(PecContact).where(PecContact.tenant_id == tenant_id)
if q:
pattern = f"%{q.lower()}%"
query = query.where(
or_(
PecContact.email.ilike(pattern),
PecContact.name.ilike(pattern),
PecContact.organization.ilike(pattern),
)
)
query = query.order_by(PecContact.is_favorite.desc(), PecContact.name, PecContact.email)
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar_one()
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
items = list((await self.db.execute(query)).scalars().all())
return items, total
async def get_contact(
self, tenant_id: uuid.UUID, contact_id: uuid.UUID
) -> PecContact:
result = await self.db.execute(
select(PecContact).where(
PecContact.id == contact_id,
PecContact.tenant_id == tenant_id,
)
)
contact = result.scalar_one_or_none()
if not contact:
raise NotFoundError(f"Contatto {contact_id} non trovato")
return contact
async def create_contact(
self,
tenant_id: uuid.UUID,
data: PecContactCreate,
created_by: uuid.UUID | None = None,
) -> PecContact:
email = data.email.lower().strip()
# Verifica unicita'
existing = await self.db.execute(
select(PecContact).where(
PecContact.tenant_id == tenant_id,
PecContact.email == email,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Contatto '{email}' gia' esistente")
contact = PecContact(
tenant_id=tenant_id,
email=email,
name=data.name,
organization=data.organization,
notes=data.notes,
is_favorite=data.is_favorite,
auto_saved=False,
created_by=created_by,
)
self.db.add(contact)
await self.db.commit()
await self.db.refresh(contact)
return contact
async def update_contact(
self,
tenant_id: uuid.UUID,
contact_id: uuid.UUID,
data: PecContactUpdate,
) -> PecContact:
contact = await self.get_contact(tenant_id, contact_id)
if data.name is not None:
contact.name = data.name
if data.organization is not None:
contact.organization = data.organization
if data.notes is not None:
contact.notes = data.notes
if data.is_favorite is not None:
contact.is_favorite = data.is_favorite
await self.db.commit()
await self.db.refresh(contact)
return contact
async def delete_contact(
self, tenant_id: uuid.UUID, contact_id: uuid.UUID
) -> None:
contact = await self.get_contact(tenant_id, contact_id)
await self.db.delete(contact)
await self.db.commit()
async def auto_save_sender(
self,
tenant_id: uuid.UUID,
email: str,
) -> None:
"""
Salva automaticamente il mittente nella rubrica se non esiste ancora.
Operazione non bloccante: gli errori vengono ignorati silenziosamente.
Usata dal worker IMAP durante la sincronizzazione dei messaggi inbound.
"""
if not email:
return
email = email.lower().strip()
try:
# Upsert: inserisce solo se non esiste
stmt = insert(PecContact).values(
id=uuid.uuid4(),
tenant_id=tenant_id,
email=email,
auto_saved=True,
).on_conflict_do_nothing(
constraint="uq_pec_contact_email_tenant"
)
await self.db.execute(stmt)
await self.db.commit()
except Exception:
await self.db.rollback()
async def import_csv(
self,
tenant_id: uuid.UUID,
csv_content: str,
created_by: uuid.UUID | None = None,
) -> PecContactImportResult:
"""
Importa contatti da un CSV con colonne: email, name, organization.
Aggiorna i record esistenti con name/organization se forniti.
"""
result = PecContactImportResult(created=0, updated=0, skipped=0)
reader = csv.DictReader(io.StringIO(csv_content))
for row_num, row in enumerate(reader, start=2):
email = row.get("email", "").strip().lower()
if not email or "@" not in email:
result.skipped += 1
result.errors.append(f"Riga {row_num}: email non valida '{email}'")
continue
name = row.get("name", "").strip() or None
organization = row.get("organization", "").strip() or None
try:
existing = await self.db.execute(
select(PecContact).where(
PecContact.tenant_id == tenant_id,
PecContact.email == email,
)
)
contact = existing.scalar_one_or_none()
if contact:
# Aggiorna solo se i campi erano vuoti (non sovrascrive dati manuali)
updated = False
if name and not contact.name:
contact.name = name
updated = True
if organization and not contact.organization:
contact.organization = organization
updated = True
if updated:
result.updated += 1
else:
result.skipped += 1
else:
contact = PecContact(
tenant_id=tenant_id,
email=email,
name=name,
organization=organization,
auto_saved=False,
created_by=created_by,
)
self.db.add(contact)
result.created += 1
except Exception as e:
result.errors.append(f"Riga {row_num} ({email}): {e}")
result.skipped += 1
await self.db.commit()
return result
async def search_for_autocomplete(
self,
tenant_id: uuid.UUID,
q: str,
limit: int = 10,
) -> list[PecContact]:
"""Ricerca veloce per autocomplete nel compose."""
if not q or len(q) < 2:
return []
pattern = f"%{q.lower()}%"
result = await self.db.execute(
select(PecContact)
.where(
PecContact.tenant_id == tenant_id,
or_(
PecContact.email.ilike(pattern),
PecContact.name.ilike(pattern),
)
)
.order_by(PecContact.is_favorite.desc(), PecContact.email)
.limit(limit)
)
return list(result.scalars().all())
@@ -0,0 +1,296 @@
"""
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
+15 -2
View File
@@ -166,12 +166,16 @@ class SendService:
now = datetime.now(tz=timezone.utc)
has_files = bool(attachments)
# Invio differito: il messaggio parte in stato 'draft' se programmato
scheduled_at = getattr(data, "scheduled_at", None)
is_scheduled = scheduled_at is not None and scheduled_at > now
message = Message(
tenant_id=current_user.tenant_id,
mailbox_id=data.mailbox_id,
direction="outbound",
pec_type="posta_certificata",
state="queued",
state="draft" if is_scheduled else "queued",
subject=data.subject,
from_address=mailbox.email_address,
to_addresses=[str(a) for a in data.to_addresses],
@@ -211,6 +215,7 @@ class SendService:
max_attempts=5,
created_by=current_user.id,
queued_at=now,
scheduled_at=scheduled_at if is_scheduled else None,
)
self.db.add(job)
await self.db.flush()
@@ -218,7 +223,15 @@ class SendService:
# ── Enqueue job arq ───────────────────────────────────────────────────
try:
arq_pool = await _get_arq_pool()
await arq_pool.enqueue_job("send_pec", str(job.id))
if is_scheduled and scheduled_at:
# Invio differito: defer_until = scheduled_at
await arq_pool.enqueue_job(
"send_pec",
str(job.id),
_defer_until=scheduled_at,
)
else:
await arq_pool.enqueue_job("send_pec", str(job.id))
except Exception as e:
from app.core.logging import get_logger
logger = get_logger(__name__)
+116
View File
@@ -0,0 +1,116 @@
"""
Service per la gestione dei template messaggi (Feature 1).
"""
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.models.template import MessageTemplate
from app.schemas.template import TemplateCreate, TemplateUpdate
class TemplateService:
def __init__(self, db: AsyncSession):
self.db = db
async def list_templates(
self,
tenant_id: uuid.UUID,
q: str | None = None,
) -> tuple[list[MessageTemplate], int]:
query = select(MessageTemplate).where(MessageTemplate.tenant_id == tenant_id)
if q:
query = query.where(MessageTemplate.name.ilike(f"%{q}%"))
query = query.order_by(MessageTemplate.name)
count_q = select(func.count()).select_from(query.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_template(
self, tenant_id: uuid.UUID, template_id: uuid.UUID
) -> MessageTemplate:
result = await self.db.execute(
select(MessageTemplate).where(
MessageTemplate.id == template_id,
MessageTemplate.tenant_id == tenant_id,
)
)
template = result.scalar_one_or_none()
if not template:
raise NotFoundError(f"Template {template_id} non trovato")
return template
async def create_template(
self,
tenant_id: uuid.UUID,
data: TemplateCreate,
created_by: uuid.UUID | None = None,
) -> MessageTemplate:
# Verifica unicita' nome
existing = await self.db.execute(
select(MessageTemplate).where(
MessageTemplate.tenant_id == tenant_id,
MessageTemplate.name == data.name,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Template '{data.name}' gia' esistente")
template = MessageTemplate(
tenant_id=tenant_id,
name=data.name,
description=data.description,
subject=data.subject,
body_text=data.body_text,
body_html=data.body_html,
created_by=created_by,
)
self.db.add(template)
await self.db.commit()
await self.db.refresh(template)
return template
async def update_template(
self,
tenant_id: uuid.UUID,
template_id: uuid.UUID,
data: TemplateUpdate,
) -> MessageTemplate:
template = await self.get_template(tenant_id, template_id)
if data.name is not None:
existing = await self.db.execute(
select(MessageTemplate).where(
MessageTemplate.tenant_id == tenant_id,
MessageTemplate.name == data.name,
MessageTemplate.id != template_id,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Template '{data.name}' gia' esistente")
template.name = data.name
if data.description is not None:
template.description = data.description
if data.subject is not None:
template.subject = data.subject
if data.body_text is not None:
template.body_text = data.body_text
if data.body_html is not None:
template.body_html = data.body_html
await self.db.commit()
await self.db.refresh(template)
return template
async def delete_template(
self, tenant_id: uuid.UUID, template_id: uuid.UUID
) -> None:
template = await self.get_template(tenant_id, template_id)
await self.db.delete(template)
await self.db.commit()