mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Fix notification System
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Email SMTP sender – invio notifiche via SMTP con TLS/SSL o STARTTLS.
|
||||||
|
|
||||||
|
Config non sensibile (config):
|
||||||
|
{
|
||||||
|
"smtp_host": "smtp.example.com",
|
||||||
|
"smtp_port": 465,
|
||||||
|
"smtp_use_tls": true, # SSL/TLS diretto (porta 465)
|
||||||
|
"smtp_use_starttls": false, # STARTTLS (porta 587) – alternativo a use_tls
|
||||||
|
"from_email": "noreply@example.com",
|
||||||
|
"from_name": "PEChub Notifiche",
|
||||||
|
"to_email": "destinatario@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
Config sensibile (config_enc → config_secret):
|
||||||
|
{ "smtp_password": "..." }
|
||||||
|
|
||||||
|
Dipendenza: aiosmtplib (gia' in backend/pyproject.toml)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
import aiosmtplib
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSMTPError(Exception):
|
||||||
|
"""Errore durante l'invio di un'email di notifica."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, smtp_code: int | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.smtp_code = smtp_code
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email_notification(
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
smtp_user: str,
|
||||||
|
smtp_password: str,
|
||||||
|
from_email: str,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
body_html: str | None = None,
|
||||||
|
from_name: str = "PEChub Notifiche",
|
||||||
|
use_tls: bool = True,
|
||||||
|
use_starttls: bool = False,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Invia un'email di notifica via SMTP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
smtp_host: host SMTP
|
||||||
|
smtp_port: porta SMTP
|
||||||
|
smtp_user: username autenticazione
|
||||||
|
smtp_password: password autenticazione
|
||||||
|
from_email: indirizzo mittente
|
||||||
|
to_email: indirizzo destinatario
|
||||||
|
subject: oggetto email
|
||||||
|
body_text: testo plain
|
||||||
|
body_html: testo HTML (opzionale)
|
||||||
|
from_name: nome visualizzato mittente
|
||||||
|
use_tls: usa SSL/TLS diretto (porta 465)
|
||||||
|
use_starttls: usa STARTTLS (porta 587) – alternativo a use_tls
|
||||||
|
timeout: timeout connessione in secondi
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EmailSMTPError: in caso di errori di autenticazione, connessione o invio
|
||||||
|
"""
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
|
||||||
|
msg["To"] = to_email
|
||||||
|
msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||||
|
msg["X-Mailer"] = "PEChub/1.0"
|
||||||
|
|
||||||
|
msg.attach(MIMEText(body_text, "plain", "utf-8"))
|
||||||
|
if body_html:
|
||||||
|
msg.attach(MIMEText(body_html, "html", "utf-8"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await aiosmtplib.send(
|
||||||
|
msg,
|
||||||
|
hostname=smtp_host,
|
||||||
|
port=smtp_port,
|
||||||
|
username=smtp_user,
|
||||||
|
password=smtp_password,
|
||||||
|
use_tls=use_tls,
|
||||||
|
start_tls=use_starttls,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except aiosmtplib.SMTPAuthenticationError as exc:
|
||||||
|
raise EmailSMTPError(
|
||||||
|
f"Autenticazione SMTP fallita per {smtp_user}@{smtp_host}: {exc}",
|
||||||
|
smtp_code=535,
|
||||||
|
) from exc
|
||||||
|
except aiosmtplib.SMTPConnectError as exc:
|
||||||
|
raise EmailSMTPError(
|
||||||
|
f"Connessione SMTP fallita a {smtp_host}:{smtp_port}: {exc}"
|
||||||
|
) from exc
|
||||||
|
except aiosmtplib.SMTPServerDisconnected as exc:
|
||||||
|
raise EmailSMTPError(
|
||||||
|
f"Server SMTP {smtp_host} ha chiuso la connessione: {exc}"
|
||||||
|
) from exc
|
||||||
|
except aiosmtplib.SMTPException as exc:
|
||||||
|
raise EmailSMTPError(f"Errore SMTP: {exc}") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise EmailSMTPError(f"Errore invio email: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def send_test_email(
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
smtp_user: str,
|
||||||
|
smtp_password: str,
|
||||||
|
from_email: str,
|
||||||
|
to_email: str,
|
||||||
|
channel_name: str = "PEChub",
|
||||||
|
from_name: str = "PEChub Notifiche",
|
||||||
|
use_tls: bool = True,
|
||||||
|
use_starttls: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Invia un'email di test per verificare la configurazione del canale.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EmailSMTPError: se la connessione o l'autenticazione falliscono
|
||||||
|
"""
|
||||||
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
subject = f"[PEChub] Test canale email: {channel_name}"
|
||||||
|
body_text = (
|
||||||
|
f"PEChub – Test canale Email\n\n"
|
||||||
|
f"Il canale '{channel_name}' e' configurato correttamente.\n\n"
|
||||||
|
f"Data/ora: {ts}\n"
|
||||||
|
f"Destinatario: {to_email}"
|
||||||
|
)
|
||||||
|
body_html = (
|
||||||
|
f"<h2>PEChub – Test canale Email</h2>"
|
||||||
|
f"<p>Il canale <strong>{channel_name}</strong> e' configurato correttamente.</p>"
|
||||||
|
f"<p>Data/ora: <em>{ts}</em><br>"
|
||||||
|
f"Destinatario: {to_email}</p>"
|
||||||
|
f"<hr><p style='font-size:11px;color:#888'>Inviato da PEChub Notification Engine</p>"
|
||||||
|
)
|
||||||
|
await send_email_notification(
|
||||||
|
smtp_host=smtp_host,
|
||||||
|
smtp_port=smtp_port,
|
||||||
|
smtp_user=smtp_user,
|
||||||
|
smtp_password=smtp_password,
|
||||||
|
from_email=from_email,
|
||||||
|
to_email=to_email,
|
||||||
|
subject=subject,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
from_name=from_name,
|
||||||
|
use_tls=use_tls,
|
||||||
|
use_starttls=use_starttls,
|
||||||
|
)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Webhook sender – POST HTTP con firma HMAC-SHA256.
|
||||||
|
|
||||||
|
Config non sensibile (config):
|
||||||
|
{ "url": "https://...", "content_type": "application/json" }
|
||||||
|
|
||||||
|
Config sensibile (config_enc → config_secret):
|
||||||
|
{ "webhook_secret": "..." } # opzionale – usato per firma HMAC
|
||||||
|
|
||||||
|
Header inviati:
|
||||||
|
Content-Type: application/json
|
||||||
|
X-PEChub-Event: {event_type}
|
||||||
|
X-Hub-Signature-256: sha256={hex} (solo se webhook_secret configurato)
|
||||||
|
X-Delivery: {uuid}
|
||||||
|
User-Agent: PEChub-Webhook/1.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import uuid as uuid_mod
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookError(Exception):
|
||||||
|
"""Errore durante l'invio di una notifica webhook."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, http_status: int | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.http_status = http_status
|
||||||
|
|
||||||
|
|
||||||
|
async def send_webhook(
|
||||||
|
url: str,
|
||||||
|
payload: dict,
|
||||||
|
event_type: str = "new_message",
|
||||||
|
webhook_secret: str | None = None,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Invia un payload JSON a un webhook URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL destinatario del webhook
|
||||||
|
payload: dict serializzato come JSON nel body
|
||||||
|
event_type: valore dell'header X-PEChub-Event
|
||||||
|
webhook_secret: segreto per firma HMAC-SHA256 (opzionale)
|
||||||
|
timeout: timeout HTTP in secondi
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con http_status, response_text, delivery_id
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WebhookError: in caso di timeout, errore di rete o HTTP >= 400
|
||||||
|
"""
|
||||||
|
body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
||||||
|
delivery_id = str(uuid_mod.uuid4())
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-PEChub-Event": event_type,
|
||||||
|
"X-Delivery": delivery_id,
|
||||||
|
"User-Agent": "PEChub-Webhook/1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
if webhook_secret:
|
||||||
|
sig = hmac.new(
|
||||||
|
webhook_secret.encode("utf-8"),
|
||||||
|
body,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
headers["X-Hub-Signature-256"] = f"sha256={sig}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(url, content=body, headers=headers)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise WebhookError(
|
||||||
|
f"Timeout webhook dopo {timeout}s"
|
||||||
|
) from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise WebhookError(f"Errore di rete webhook: {exc}") from exc
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise WebhookError(
|
||||||
|
f"Webhook ha risposto con HTTP {response.status_code}: "
|
||||||
|
f"{response.text[:200]}",
|
||||||
|
http_status=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"http_status": response.status_code,
|
||||||
|
"response_text": response.text[:500],
|
||||||
|
"delivery_id": delivery_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_test_webhook(
|
||||||
|
url: str,
|
||||||
|
webhook_secret: str | None = None,
|
||||||
|
channel_name: str = "PEChub",
|
||||||
|
) -> dict:
|
||||||
|
"""Invia un payload di test al webhook per verificare la configurazione."""
|
||||||
|
payload = {
|
||||||
|
"event": "test",
|
||||||
|
"channel": channel_name,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"message": "Notifica di test da PEChub",
|
||||||
|
"data": {
|
||||||
|
"subject": "[TEST] PEC di prova",
|
||||||
|
"from_address": "test@pec.example.it",
|
||||||
|
"pec_type": "posta_certificata",
|
||||||
|
"direction": "inbound",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return await send_webhook(
|
||||||
|
url=url,
|
||||||
|
payload=payload,
|
||||||
|
event_type="test",
|
||||||
|
webhook_secret=webhook_secret,
|
||||||
|
)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
WhatsApp sender – Meta Cloud API v18.
|
||||||
|
|
||||||
|
Config non sensibile (config):
|
||||||
|
{
|
||||||
|
"phone_number_id": "123456789", # ID numero mittente Meta Business
|
||||||
|
"to_phone": "+393331234567" # numero destinatario con prefisso
|
||||||
|
}
|
||||||
|
|
||||||
|
Config sensibile (config_enc → config_secret):
|
||||||
|
{ "access_token": "EAABs..." } # Meta Graph API token
|
||||||
|
|
||||||
|
API endpoint:
|
||||||
|
POST https://graph.facebook.com/v18.0/{phone_number_id}/messages
|
||||||
|
|
||||||
|
Nota: richiede un account Meta Business verificato con WhatsApp Business API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
META_GRAPH_API_URL = "https://graph.facebook.com/v18.0"
|
||||||
|
DEFAULT_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppError(Exception):
|
||||||
|
"""Errore durante l'invio di un messaggio WhatsApp."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.http_status = http_status
|
||||||
|
self.api_code = api_code
|
||||||
|
|
||||||
|
|
||||||
|
async def send_whatsapp_message(
|
||||||
|
phone_number_id: str,
|
||||||
|
to_phone: str,
|
||||||
|
text: str,
|
||||||
|
access_token: str,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Invia un messaggio di testo WhatsApp via Meta Cloud API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
phone_number_id: ID del numero WhatsApp Business mittente
|
||||||
|
to_phone: numero destinatario (formato E.164, es. +393331234567)
|
||||||
|
text: testo del messaggio
|
||||||
|
access_token: Meta Graph API Bearer token
|
||||||
|
timeout: timeout HTTP in secondi
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con message_id dalla risposta API
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WhatsAppError: in caso di errore HTTP o risposta API non-ok
|
||||||
|
"""
|
||||||
|
url = f"{META_GRAPH_API_URL}/{phone_number_id}/messages"
|
||||||
|
payload = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"to": to_phone.replace(" ", "").replace("-", ""),
|
||||||
|
"type": "text",
|
||||||
|
"text": {"body": text},
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(url, json=payload, headers=headers)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise WhatsAppError(
|
||||||
|
f"Timeout WhatsApp API dopo {timeout}s"
|
||||||
|
) from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise WhatsAppError(f"Errore di rete WhatsApp: {exc}") from exc
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise WhatsAppError(
|
||||||
|
"Token Meta non valido o scaduto",
|
||||||
|
http_status=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
try:
|
||||||
|
err_data = response.json()
|
||||||
|
err_msg = err_data.get("error", {}).get("message", response.text[:200])
|
||||||
|
err_code = err_data.get("error", {}).get("code")
|
||||||
|
except Exception:
|
||||||
|
err_msg = response.text[:200]
|
||||||
|
err_code = None
|
||||||
|
raise WhatsAppError(
|
||||||
|
f"Meta API errore HTTP {response.status_code}: {err_msg}",
|
||||||
|
http_status=response.status_code,
|
||||||
|
api_code=err_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
messages = data.get("messages", [])
|
||||||
|
message_id = messages[0].get("id") if messages else None
|
||||||
|
return {"message_id": message_id, "http_status": response.status_code}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_test_whatsapp(
|
||||||
|
phone_number_id: str,
|
||||||
|
to_phone: str,
|
||||||
|
access_token: str,
|
||||||
|
channel_name: str = "PEChub",
|
||||||
|
) -> dict:
|
||||||
|
"""Invia un messaggio WhatsApp di test per verificare la configurazione."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
text = (
|
||||||
|
f"*PEChub – Test canale WhatsApp*\n\n"
|
||||||
|
f"Il canale _{channel_name}_ e' configurato correttamente.\n\n"
|
||||||
|
f"Data/ora: {ts}"
|
||||||
|
)
|
||||||
|
return await send_whatsapp_message(
|
||||||
|
phone_number_id=phone_number_id,
|
||||||
|
to_phone=to_phone,
|
||||||
|
text=text,
|
||||||
|
access_token=access_token,
|
||||||
|
)
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
Servizio Notifiche Multi-canale – CRUD canali, regole, log.
|
Servizio Notifiche Multi-canale – CRUD canali, regole, log + dispatch.
|
||||||
|
|
||||||
Nota: la cifratura AES-256-GCM di config_enc avviene qui usando
|
Cifratura: AES-256-GCM via libreria cryptography.
|
||||||
la NOTIFICATION_SECRET_KEY dalla config. Per semplicità in questo
|
Formato config_enc: base64( nonce(12) || ciphertext+tag )
|
||||||
stub usiamo Fernet (libreria cryptography), facilmente sostituibile
|
Chiave: ENCRYPTION_KEY (hex 64 char = 32 byte) dalla config.
|
||||||
con una implementazione GCM dedicata.
|
|
||||||
|
Backward compatibility: se il valore non decrittografa come GCM, viene
|
||||||
|
tentato il fallback a base64 grezzo (configurazioni precedenti al fix).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.core.exceptions import NotFoundError
|
from app.core.exceptions import NotFoundError
|
||||||
@@ -27,20 +31,53 @@ from app.schemas.notification import (
|
|||||||
NotificationRuleUpdate,
|
NotificationRuleUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
def _encrypt(data: dict) -> str:
|
# ─── Cifratura AES-256-GCM ────────────────────────────────────────────────────
|
||||||
"""Cifra un dict JSON → base64. Usa la SECRET_KEY come seed."""
|
|
||||||
# In produzione: usa AES-256-GCM. Qui: semplice base64 con marker.
|
def _encrypt(data: dict, key: bytes | None = None) -> str:
|
||||||
raw = json.dumps(data).encode()
|
"""
|
||||||
return base64.b64encode(raw).decode()
|
Cifra un dict JSON con AES-256-GCM.
|
||||||
|
|
||||||
|
Formato output: base64( nonce(12 byte) || ciphertext+tag(16 byte) )
|
||||||
|
"""
|
||||||
|
if key is None:
|
||||||
|
key = settings.encryption_key_bytes
|
||||||
|
nonce = os.urandom(12)
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
plaintext = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext, None) # include tag
|
||||||
|
return base64.b64encode(nonce + ciphertext).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
def _decrypt(enc: str) -> dict:
|
def _decrypt(enc: str, key: bytes | None = None) -> dict:
|
||||||
"""Decifra il valore restituito da _encrypt."""
|
"""
|
||||||
raw = base64.b64decode(enc.encode())
|
Decifra il valore prodotto da _encrypt.
|
||||||
return json.loads(raw.decode())
|
|
||||||
|
Backward compatible: se il dato non e' GCM valido, prova il
|
||||||
|
vecchio base64 grezzo (usato prima del fix di sicurezza).
|
||||||
|
"""
|
||||||
|
if key is None:
|
||||||
|
key = settings.encryption_key_bytes
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(enc.encode("ascii"))
|
||||||
|
if len(raw) > 28: # 12 nonce + 16 tag minimo
|
||||||
|
nonce = raw[:12]
|
||||||
|
ciphertext = raw[12:]
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
return json.loads(plaintext.decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: base64 grezzo (configurazioni create prima del fix GCM)
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(enc.encode("ascii"))
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
@@ -113,6 +150,7 @@ class NotificationService:
|
|||||||
if data.config is not None:
|
if data.config is not None:
|
||||||
channel.config = data.config
|
channel.config = data.config
|
||||||
if data.config_secret is not None:
|
if data.config_secret is not None:
|
||||||
|
# Re-cifra sempre con AES-256-GCM (aggiorna anche i vecchi base64)
|
||||||
channel.config_enc = _encrypt(data.config_secret)
|
channel.config_enc = _encrypt(data.config_secret)
|
||||||
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
@@ -128,18 +166,16 @@ class NotificationService:
|
|||||||
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
|
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
|
||||||
) -> ChannelTestResult:
|
) -> ChannelTestResult:
|
||||||
"""
|
"""
|
||||||
Invia un messaggio di test al canale configurato.
|
Invia un messaggio di test reale al canale configurato.
|
||||||
|
|
||||||
Questa implementazione stub restituisce sempre successo se il canale
|
Esegue invio effettivo per Telegram, Webhook, Email SMTP e WhatsApp.
|
||||||
è attivo e configurato. Una implementazione completa fa una chiamata
|
|
||||||
reale al canale (HTTP/SMTP/Telegram/WhatsApp).
|
|
||||||
"""
|
"""
|
||||||
channel = await self.get_channel(channel_id, tenant_id)
|
channel = await self.get_channel(channel_id, tenant_id)
|
||||||
|
|
||||||
if not channel.is_active:
|
if not channel.is_active:
|
||||||
return ChannelTestResult(
|
return ChannelTestResult(
|
||||||
success=False,
|
success=False,
|
||||||
message="Il canale è disabilitato",
|
message="Il canale e' disabilitato",
|
||||||
)
|
)
|
||||||
|
|
||||||
if channel.circuit_open_until and channel.circuit_open_until > datetime.now(timezone.utc):
|
if channel.circuit_open_until and channel.circuit_open_until > datetime.now(timezone.utc):
|
||||||
@@ -148,21 +184,23 @@ class NotificationService:
|
|||||||
message=f"Circuit breaker aperto fino a {channel.circuit_open_until.isoformat()}",
|
message=f"Circuit breaker aperto fino a {channel.circuit_open_until.isoformat()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validazione configurazione minima per tipo canale
|
|
||||||
config = channel.config or {}
|
config = channel.config or {}
|
||||||
|
secret = {}
|
||||||
|
if channel.config_enc:
|
||||||
|
try:
|
||||||
|
secret = _decrypt(channel.config_enc)
|
||||||
|
except Exception as e:
|
||||||
|
return ChannelTestResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Errore decifratura configurazione sensibile: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
channel_type = channel.channel_type
|
channel_type = channel.channel_type
|
||||||
|
|
||||||
if channel_type == "webhook":
|
# ── Telegram ──────────────────────────────────────────────────────────
|
||||||
if not config.get("url"):
|
if channel_type == "telegram":
|
||||||
return ChannelTestResult(success=False, message="URL webhook non configurato")
|
|
||||||
elif channel_type == "email":
|
|
||||||
if not config.get("to_email"):
|
|
||||||
return ChannelTestResult(success=False, message="Email destinatario non configurata")
|
|
||||||
elif channel_type == "telegram":
|
|
||||||
if not config.get("chat_id"):
|
if not config.get("chat_id"):
|
||||||
return ChannelTestResult(success=False, message="Chat ID Telegram non configurato")
|
return ChannelTestResult(success=False, message="Chat ID Telegram non configurato")
|
||||||
# Invio reale via Bot API
|
|
||||||
secret = _decrypt(channel.config_enc) if channel.config_enc else {}
|
|
||||||
bot_token = secret.get("bot_token")
|
bot_token = secret.get("bot_token")
|
||||||
if not bot_token:
|
if not bot_token:
|
||||||
return ChannelTestResult(success=False, message="Bot token Telegram non configurato")
|
return ChannelTestResult(success=False, message="Bot token Telegram non configurato")
|
||||||
@@ -176,29 +214,129 @@ class NotificationService:
|
|||||||
msg_id = result.get("message_id")
|
msg_id = result.get("message_id")
|
||||||
return ChannelTestResult(
|
return ChannelTestResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Messaggio Telegram inviato con successo (message_id={msg_id}).",
|
message=f"Messaggio Telegram inviato (message_id={msg_id}).",
|
||||||
http_status=200,
|
http_status=200,
|
||||||
)
|
)
|
||||||
except TelegramError as exc:
|
|
||||||
return ChannelTestResult(
|
|
||||||
success=False,
|
|
||||||
message=f"Errore Telegram: {exc}",
|
|
||||||
http_status=exc.http_status,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ChannelTestResult(
|
return ChannelTestResult(
|
||||||
success=False,
|
success=False,
|
||||||
message=f"Errore imprevisto durante il test Telegram: {exc}",
|
message=f"Errore Telegram: {exc}",
|
||||||
)
|
)
|
||||||
elif channel_type == "whatsapp":
|
|
||||||
if not config.get("phone_number"):
|
|
||||||
return ChannelTestResult(success=False, message="Numero WhatsApp non configurato")
|
|
||||||
|
|
||||||
|
# ── Webhook ───────────────────────────────────────────────────────────
|
||||||
|
elif channel_type == "webhook":
|
||||||
|
url = config.get("url")
|
||||||
|
if not url:
|
||||||
|
return ChannelTestResult(success=False, message="URL webhook non configurato")
|
||||||
|
webhook_secret = secret.get("webhook_secret")
|
||||||
|
try:
|
||||||
|
from app.notifications.webhook import WebhookError, send_test_webhook
|
||||||
|
result = await send_test_webhook(
|
||||||
|
url=url,
|
||||||
|
webhook_secret=webhook_secret,
|
||||||
|
channel_name=channel.name,
|
||||||
|
)
|
||||||
return ChannelTestResult(
|
return ChannelTestResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Canale {channel_type} configurato correttamente. Test simulato con successo.",
|
message=(
|
||||||
|
f"Webhook raggiunto con successo "
|
||||||
|
f"(HTTP {result['http_status']}, delivery={result['delivery_id']})."
|
||||||
|
),
|
||||||
|
http_status=result["http_status"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
http_status = getattr(exc, "http_status", None)
|
||||||
|
return ChannelTestResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Errore webhook: {exc}",
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Email SMTP ────────────────────────────────────────────────────────
|
||||||
|
elif channel_type == "email":
|
||||||
|
smtp_host = config.get("smtp_host")
|
||||||
|
smtp_port = config.get("smtp_port", 465)
|
||||||
|
from_email = config.get("from_email")
|
||||||
|
to_email = config.get("to_email")
|
||||||
|
smtp_user = config.get("smtp_user") or from_email
|
||||||
|
use_tls = config.get("smtp_use_tls", True)
|
||||||
|
use_starttls = config.get("smtp_use_starttls", False)
|
||||||
|
from_name = config.get("from_name", "PEChub Notifiche")
|
||||||
|
smtp_password = secret.get("smtp_password", "")
|
||||||
|
|
||||||
|
if not smtp_host:
|
||||||
|
return ChannelTestResult(success=False, message="Host SMTP non configurato")
|
||||||
|
if not from_email:
|
||||||
|
return ChannelTestResult(success=False, message="Email mittente non configurata")
|
||||||
|
if not to_email:
|
||||||
|
return ChannelTestResult(success=False, message="Email destinatario non configurata")
|
||||||
|
if not smtp_password:
|
||||||
|
return ChannelTestResult(success=False, message="Password SMTP non configurata")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.notifications.email_smtp import EmailSMTPError, send_test_email
|
||||||
|
await send_test_email(
|
||||||
|
smtp_host=smtp_host,
|
||||||
|
smtp_port=int(smtp_port),
|
||||||
|
smtp_user=smtp_user,
|
||||||
|
smtp_password=smtp_password,
|
||||||
|
from_email=from_email,
|
||||||
|
to_email=to_email,
|
||||||
|
channel_name=channel.name,
|
||||||
|
from_name=from_name,
|
||||||
|
use_tls=use_tls,
|
||||||
|
use_starttls=use_starttls,
|
||||||
|
)
|
||||||
|
return ChannelTestResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Email di test inviata con successo a {to_email}.",
|
||||||
http_status=200,
|
http_status=200,
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ChannelTestResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Errore email: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── WhatsApp ──────────────────────────────────────────────────────────
|
||||||
|
elif channel_type == "whatsapp":
|
||||||
|
phone_number_id = config.get("phone_number_id")
|
||||||
|
to_phone = config.get("to_phone")
|
||||||
|
access_token = secret.get("access_token")
|
||||||
|
|
||||||
|
if not phone_number_id:
|
||||||
|
return ChannelTestResult(success=False, message="phone_number_id non configurato")
|
||||||
|
if not to_phone:
|
||||||
|
return ChannelTestResult(success=False, message="Numero WhatsApp destinatario non configurato")
|
||||||
|
if not access_token:
|
||||||
|
return ChannelTestResult(success=False, message="Access token Meta non configurato")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.notifications.whatsapp import WhatsAppError, send_test_whatsapp
|
||||||
|
result = await send_test_whatsapp(
|
||||||
|
phone_number_id=phone_number_id,
|
||||||
|
to_phone=to_phone,
|
||||||
|
access_token=access_token,
|
||||||
|
channel_name=channel.name,
|
||||||
|
)
|
||||||
|
return ChannelTestResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Messaggio WhatsApp inviato (message_id={result.get('message_id')}).",
|
||||||
|
http_status=200,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
http_status = getattr(exc, "http_status", None)
|
||||||
|
return ChannelTestResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Errore WhatsApp: {exc}",
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Tipo sconosciuto ──────────────────────────────────────────────────
|
||||||
|
return ChannelTestResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Tipo canale '{channel_type}' non supportato",
|
||||||
|
)
|
||||||
|
|
||||||
# ─── Rules ───────────────────────────────────────────────────────────────
|
# ─── Rules ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.jobs.dispatch_notification import evaluate_and_enqueue_notifications
|
||||||
from app.jobs.index_message import index_message
|
from app.jobs.index_message import index_message
|
||||||
from app.models import Attachment, Mailbox, Message
|
from app.models import Attachment, Mailbox, Message
|
||||||
from app.parsers.eml_parser import parse_eml
|
from app.parsers.eml_parser import parse_eml
|
||||||
@@ -646,6 +647,18 @@ async def _save_message(
|
|||||||
# sia il messaggio che gli allegati dalla sessione corrente.
|
# sia il messaggio che gli allegati dalla sessione corrente.
|
||||||
await index_message(message.id, db)
|
await index_message(message.id, db)
|
||||||
|
|
||||||
|
# ── Valutazione e accodamento notifiche (non bloccante) ───────────────────
|
||||||
|
# Solo per messaggi inbound: le ricevute PEC e la posta in arrivo
|
||||||
|
# possono triggerare regole di notifica configurate dal tenant.
|
||||||
|
# I messaggi outbound (Sent) non generano notifiche automatiche.
|
||||||
|
if direction == "inbound":
|
||||||
|
await evaluate_and_enqueue_notifications(
|
||||||
|
message=message,
|
||||||
|
mailbox=mailbox,
|
||||||
|
db=db,
|
||||||
|
redis_client=redis_client,
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,666 @@
|
|||||||
|
"""
|
||||||
|
Job arq: dispatch_notification – invio effettivo delle notifiche multi-canale.
|
||||||
|
|
||||||
|
Flusso completo:
|
||||||
|
1. sync.py salva un nuovo messaggio inbound
|
||||||
|
2. sync.py chiama evaluate_and_enqueue_notifications() (questa funzione)
|
||||||
|
3. evaluate_and_enqueue_notifications():
|
||||||
|
- legge le NotificationRule attive del tenant con event_type="new_message"
|
||||||
|
- applica i filtri opzionali (mailbox_id, direction, pec_type)
|
||||||
|
- per ogni regola che matcha: crea NotificationLog(status=pending)
|
||||||
|
- enqueue del job arq dispatch_notification con defer 5s
|
||||||
|
(per dare tempo al DB di committare prima che il job legga)
|
||||||
|
4. dispatch_notification(ctx, notification_log_id):
|
||||||
|
- legge NotificationLog + NotificationChannel dal DB
|
||||||
|
- controlla circuit breaker
|
||||||
|
- decifra config_enc con AES-256-GCM
|
||||||
|
- chiama il sender appropriato (telegram/webhook/email/whatsapp)
|
||||||
|
- aggiorna status: sent / failed
|
||||||
|
- in caso di fallimento: re-enqueue con backoff esponenziale
|
||||||
|
- aggiorna circuit breaker dopo fallimenti consecutivi
|
||||||
|
|
||||||
|
Circuit breaker:
|
||||||
|
- 5+ fallimenti consecutivi → apre per 1 ora
|
||||||
|
- reset automatico al primo successo
|
||||||
|
|
||||||
|
Retry backoff (max_attempts default=3):
|
||||||
|
Tentativo 1 fallisce → attendi 5 min → tentativo 2
|
||||||
|
Tentativo 2 fallisce → attendi 30 min → tentativo 3
|
||||||
|
Tentativo 3 fallisce → FAILED definitivo
|
||||||
|
|
||||||
|
Cifratura config_enc:
|
||||||
|
AES-256-GCM – stesso schema di notification_service.py nel backend.
|
||||||
|
Backward compatible: fallback a base64 grezzo per configurazioni pre-fix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models import (
|
||||||
|
Mailbox,
|
||||||
|
Message,
|
||||||
|
NotificationChannel,
|
||||||
|
NotificationLog,
|
||||||
|
NotificationRule,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# ─── Backoff retry (secondi) per tentativo N fallito (0-based) ───────────────
|
||||||
|
_RETRY_DELAYS = [
|
||||||
|
5 * 60, # dopo tentativo 1: 5 minuti
|
||||||
|
30 * 60, # dopo tentativo 2: 30 minuti
|
||||||
|
120 * 60, # dopo tentativo 3: 2 ore (non raggiunto: max_attempts=3)
|
||||||
|
]
|
||||||
|
|
||||||
|
# ─── Circuit breaker ──────────────────────────────────────────────────────────
|
||||||
|
_CIRCUIT_FAILURE_THRESHOLD = 5 # fallimenti consecutivi per aprire il circuito
|
||||||
|
_CIRCUIT_OPEN_DURATION = timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Cifratura AES-256-GCM ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _decrypt_config(enc: str) -> dict:
|
||||||
|
"""
|
||||||
|
Decifra config_enc AES-256-GCM.
|
||||||
|
|
||||||
|
Backward compatible: se non e' GCM valido, prova fallback base64 grezzo.
|
||||||
|
"""
|
||||||
|
key = settings.encryption_key_bytes
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(enc.encode("ascii"))
|
||||||
|
if len(raw) > 28: # 12 nonce + 16 tag minimo
|
||||||
|
nonce = raw[:12]
|
||||||
|
ciphertext = raw[12:]
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
return json.loads(plaintext.decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: base64 grezzo (configurazioni pre-fix sicurezza)
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(enc.encode("ascii"))
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Valutazione filtri regola ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _matches_filter(
|
||||||
|
filter_data: dict | None,
|
||||||
|
message: Message,
|
||||||
|
mailbox: Mailbox,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Valuta se un messaggio soddisfa i filtri opzionali di una regola.
|
||||||
|
|
||||||
|
Filtri supportati:
|
||||||
|
mailbox_id: str | list[str] – UUID casella
|
||||||
|
direction: str – "inbound" | "outbound"
|
||||||
|
pec_type: str | list[str] – tipo messaggio PEC
|
||||||
|
"""
|
||||||
|
if not filter_data:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Filtro per mailbox
|
||||||
|
if "mailbox_id" in filter_data:
|
||||||
|
allowed = filter_data["mailbox_id"]
|
||||||
|
if isinstance(allowed, list):
|
||||||
|
if str(message.mailbox_id) not in [str(a) for a in allowed]:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if str(message.mailbox_id) != str(allowed):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Filtro per direzione
|
||||||
|
if "direction" in filter_data:
|
||||||
|
if message.direction != filter_data["direction"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Filtro per tipo PEC
|
||||||
|
if "pec_type" in filter_data:
|
||||||
|
allowed = filter_data["pec_type"]
|
||||||
|
if isinstance(allowed, list):
|
||||||
|
if message.pec_type not in allowed:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if message.pec_type != allowed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Costruzione testo notifica ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_notification_text(
|
||||||
|
event_type: str,
|
||||||
|
payload: dict,
|
||||||
|
channel_type: str,
|
||||||
|
channel_name: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Costruisce il testo della notifica in base all'evento e al tipo canale.
|
||||||
|
"""
|
||||||
|
if event_type == "new_message":
|
||||||
|
subject = payload.get("subject") or "(senza oggetto)"
|
||||||
|
from_addr = payload.get("from_address") or "mittente sconosciuto"
|
||||||
|
mailbox_email = payload.get("mailbox_email") or payload.get("mailbox_id", "")
|
||||||
|
pec_type = payload.get("pec_type", "posta_certificata")
|
||||||
|
received_at = payload.get("received_at", "")
|
||||||
|
|
||||||
|
# Traduzione tipo PEC
|
||||||
|
pec_type_labels = {
|
||||||
|
"posta_certificata": "PEC",
|
||||||
|
"accettazione": "Ricevuta di Accettazione",
|
||||||
|
"non_accettazione": "Ricevuta di Non Accettazione",
|
||||||
|
"presa_in_carico": "Ricevuta di Presa in Carico",
|
||||||
|
"avvenuta_consegna": "Ricevuta di Avvenuta Consegna",
|
||||||
|
"mancata_consegna": "Ricevuta di Mancata Consegna",
|
||||||
|
"errore_consegna": "Ricevuta di Errore Consegna",
|
||||||
|
"preavviso_mancata_consegna": "Preavviso Mancata Consegna",
|
||||||
|
"rilevazione_virus": "Rilevazione Virus",
|
||||||
|
"unknown": "Messaggio",
|
||||||
|
}
|
||||||
|
tipo_label = pec_type_labels.get(pec_type, pec_type)
|
||||||
|
|
||||||
|
if channel_type == "telegram":
|
||||||
|
return (
|
||||||
|
f"<b>Nuovo messaggio PEC</b>\n\n"
|
||||||
|
f"<b>Tipo:</b> {tipo_label}\n"
|
||||||
|
f"<b>Da:</b> {from_addr}\n"
|
||||||
|
f"<b>Casella:</b> {mailbox_email}\n"
|
||||||
|
f"<b>Oggetto:</b> {subject}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
f"Nuovo messaggio PEC\n"
|
||||||
|
f"Tipo: {tipo_label}\n"
|
||||||
|
f"Da: {from_addr}\n"
|
||||||
|
f"Casella: {mailbox_email}\n"
|
||||||
|
f"Oggetto: {subject}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event_type == "state_changed":
|
||||||
|
message_id = payload.get("message_id", "")
|
||||||
|
old_state = payload.get("old_state", "")
|
||||||
|
new_state = payload.get("new_state", "")
|
||||||
|
subject = payload.get("subject", "")
|
||||||
|
return (
|
||||||
|
f"Cambio stato PEC\n"
|
||||||
|
f"Oggetto: {subject}\n"
|
||||||
|
f"Stato: {old_state} -> {new_state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return f"Evento PEChub: {event_type}\n{json.dumps(payload, ensure_ascii=False, default=str)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_notification_payload(event_type: str, payload: dict) -> dict:
|
||||||
|
"""Costruisce il payload JSON per il webhook."""
|
||||||
|
return {
|
||||||
|
"event": event_type,
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"data": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_email_subject(event_type: str, payload: dict) -> str:
|
||||||
|
"""Costruisce l'oggetto dell'email di notifica."""
|
||||||
|
if event_type == "new_message":
|
||||||
|
subject = payload.get("subject") or "(senza oggetto)"
|
||||||
|
return f"[PEChub] Nuovo messaggio PEC: {subject}"
|
||||||
|
elif event_type == "state_changed":
|
||||||
|
return f"[PEChub] Cambio stato PEC: {payload.get('new_state', '')}"
|
||||||
|
return f"[PEChub] Evento: {event_type}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Evaluate & Enqueue ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def evaluate_and_enqueue_notifications(
|
||||||
|
message: Message,
|
||||||
|
mailbox: Mailbox,
|
||||||
|
db: Any, # AsyncSession – evito import circolare
|
||||||
|
redis_client: Any, # ArqRedis
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Valuta le regole di notifica per un messaggio appena salvato e accoda i job.
|
||||||
|
|
||||||
|
Chiamata da sync.py dopo _save_message e index_message.
|
||||||
|
Non solleva eccezioni: gli errori vengono loggati ma non propagati per
|
||||||
|
non interrompere il flusso di sincronizzazione IMAP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: messaggio appena salvato nel DB (flush, non commit)
|
||||||
|
mailbox: casella di appartenenza
|
||||||
|
db: sessione DB (open, con flush del messaggio)
|
||||||
|
redis_client: ArqRedis per enqueue_job
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _do_evaluate_and_enqueue(message, mailbox, db, redis_client)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
f"Errore evaluate_and_enqueue_notifications per messaggio "
|
||||||
|
f"{message.id}: {exc}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_evaluate_and_enqueue(
|
||||||
|
message: Message,
|
||||||
|
mailbox: Mailbox,
|
||||||
|
db: Any,
|
||||||
|
redis_client: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Logica interna – puo' sollevare eccezioni."""
|
||||||
|
|
||||||
|
# Carica regole attive per questo tenant con event_type = "new_message"
|
||||||
|
rules_result = await db.execute(
|
||||||
|
select(NotificationRule)
|
||||||
|
.options(selectinload(NotificationRule.channel))
|
||||||
|
.where(
|
||||||
|
NotificationRule.tenant_id == message.tenant_id,
|
||||||
|
NotificationRule.is_active == True, # noqa: E712
|
||||||
|
NotificationRule.event_type == "new_message",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rules: list[NotificationRule] = list(rules_result.scalars().all())
|
||||||
|
|
||||||
|
if not rules:
|
||||||
|
return
|
||||||
|
|
||||||
|
enqueued_count = 0
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
channel = rule.channel
|
||||||
|
if not channel or not channel.is_active:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Controlla circuit breaker
|
||||||
|
if (
|
||||||
|
channel.circuit_open_until
|
||||||
|
and channel.circuit_open_until > datetime.now(UTC)
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
f"[notify] Canale {channel.name!r} circuit aperto fino a "
|
||||||
|
f"{channel.circuit_open_until}, skip regola {rule.name!r}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Applica filtri
|
||||||
|
if not _matches_filter(rule.filter, message, mailbox):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Costruisce il payload dell'evento
|
||||||
|
event_payload = {
|
||||||
|
"message_id": str(message.id),
|
||||||
|
"mailbox_id": str(mailbox.id),
|
||||||
|
"mailbox_email": mailbox.email_address,
|
||||||
|
"subject": message.subject or "",
|
||||||
|
"from_address": message.from_address or "",
|
||||||
|
"to_addresses": list(message.to_addresses or []),
|
||||||
|
"pec_type": message.pec_type,
|
||||||
|
"direction": message.direction,
|
||||||
|
"received_at": (
|
||||||
|
message.received_at.isoformat()
|
||||||
|
if message.received_at
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Crea NotificationLog
|
||||||
|
log = NotificationLog(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=message.tenant_id,
|
||||||
|
channel_id=rule.channel_id,
|
||||||
|
rule_id=rule.id,
|
||||||
|
event_type="new_message",
|
||||||
|
event_payload=event_payload,
|
||||||
|
status="pending",
|
||||||
|
attempt_count=0,
|
||||||
|
max_attempts=3,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.flush() # ottieni log.id
|
||||||
|
|
||||||
|
# Enqueue arq job con defer 5s per attendere il commit DB
|
||||||
|
try:
|
||||||
|
await redis_client.enqueue_job(
|
||||||
|
"dispatch_notification",
|
||||||
|
str(log.id),
|
||||||
|
_defer_by=timedelta(seconds=5),
|
||||||
|
)
|
||||||
|
enqueued_count += 1
|
||||||
|
logger.info(
|
||||||
|
f"[notify] Enqueued dispatch_notification per regola "
|
||||||
|
f"{rule.name!r} -> canale {channel.name!r} "
|
||||||
|
f"(log_id={log.id})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[notify] Impossibile enqueue dispatch_notification "
|
||||||
|
f"per log {log.id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if enqueued_count > 0:
|
||||||
|
logger.info(
|
||||||
|
f"[notify] Messaggio {message.id}: "
|
||||||
|
f"{enqueued_count} notifiche accodate"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Job arq principale ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def dispatch_notification(
|
||||||
|
ctx: dict[str, Any],
|
||||||
|
notification_log_id: str,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Job arq: legge un NotificationLog e invia la notifica al canale configurato.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: contesto arq (ctx["redis"] = ArqRedis)
|
||||||
|
notification_log_id: UUID del NotificationLog da processare
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con status e dettagli
|
||||||
|
"""
|
||||||
|
redis_client = ctx.get("redis")
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
log_uuid = uuid.UUID(notification_log_id)
|
||||||
|
|
||||||
|
# ── Carica log + canale ───────────────────────────────────────────────
|
||||||
|
log_result = await db.execute(
|
||||||
|
select(NotificationLog)
|
||||||
|
.options(selectinload(NotificationLog.channel))
|
||||||
|
.where(NotificationLog.id == log_uuid)
|
||||||
|
)
|
||||||
|
log: NotificationLog | None = log_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not log:
|
||||||
|
logger.warning(
|
||||||
|
f"[dispatch_notification] NotificationLog {notification_log_id} non trovato"
|
||||||
|
)
|
||||||
|
return {"status": "not_found", "log_id": notification_log_id}
|
||||||
|
|
||||||
|
if log.status in ("sent", "failed", "skipped"):
|
||||||
|
logger.debug(
|
||||||
|
f"[dispatch_notification] Log {notification_log_id} "
|
||||||
|
f"gia' in stato {log.status!r}, skip"
|
||||||
|
)
|
||||||
|
return {"status": "already_processed", "current_status": log.status}
|
||||||
|
|
||||||
|
channel = log.channel
|
||||||
|
if not channel or not channel.is_active:
|
||||||
|
log.status = "skipped"
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "skipped", "reason": "channel_inactive"}
|
||||||
|
|
||||||
|
# ── Circuit breaker ───────────────────────────────────────────────────
|
||||||
|
if (
|
||||||
|
channel.circuit_open_until
|
||||||
|
and channel.circuit_open_until > datetime.now(UTC)
|
||||||
|
):
|
||||||
|
log.status = "skipped"
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"[dispatch_notification] Canale {channel.name!r} circuit aperto, "
|
||||||
|
f"log {notification_log_id} marcato skipped"
|
||||||
|
)
|
||||||
|
return {"status": "skipped", "reason": "circuit_open"}
|
||||||
|
|
||||||
|
# ── Incrementa contatore tentativi ────────────────────────────────────
|
||||||
|
log.attempt_count += 1
|
||||||
|
current_attempt = log.attempt_count
|
||||||
|
|
||||||
|
# ── Decifra config sensibile ──────────────────────────────────────────
|
||||||
|
secret: dict = {}
|
||||||
|
if channel.config_enc:
|
||||||
|
try:
|
||||||
|
secret = _decrypt_config(channel.config_enc)
|
||||||
|
except Exception as e:
|
||||||
|
log.status = "failed"
|
||||||
|
log.last_error = f"Errore decifratura config: {e}"
|
||||||
|
await db.commit()
|
||||||
|
logger.error(
|
||||||
|
f"[dispatch_notification] Errore decifratura canale "
|
||||||
|
f"{channel.name!r}: {e}"
|
||||||
|
)
|
||||||
|
return {"status": "failed", "error": str(e)}
|
||||||
|
|
||||||
|
config = channel.config or {}
|
||||||
|
payload = log.event_payload or {}
|
||||||
|
channel_type = channel.channel_type
|
||||||
|
|
||||||
|
# ── Testo notifica ────────────────────────────────────────────────────
|
||||||
|
notif_text = _build_notification_text(
|
||||||
|
event_type=log.event_type,
|
||||||
|
payload=payload,
|
||||||
|
channel_type=channel_type,
|
||||||
|
channel_name=channel.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Dispatch al sender ────────────────────────────────────────────────
|
||||||
|
success = False
|
||||||
|
error_msg: str | None = None
|
||||||
|
http_status: int | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if channel_type == "telegram":
|
||||||
|
bot_token = secret.get("bot_token")
|
||||||
|
chat_id = str(config.get("chat_id", ""))
|
||||||
|
if not bot_token or not chat_id:
|
||||||
|
raise ValueError("bot_token o chat_id non configurati")
|
||||||
|
|
||||||
|
from app.notifications.telegram import send_message
|
||||||
|
result = await send_message(
|
||||||
|
bot_token=bot_token,
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=notif_text,
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
http_status = 200
|
||||||
|
success = True
|
||||||
|
logger.info(
|
||||||
|
f"[dispatch_notification] Telegram inviato: "
|
||||||
|
f"message_id={result.get('message_id')} "
|
||||||
|
f"canale={channel.name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif channel_type == "webhook":
|
||||||
|
url = config.get("url")
|
||||||
|
if not url:
|
||||||
|
raise ValueError("URL webhook non configurato")
|
||||||
|
webhook_secret_val = secret.get("webhook_secret")
|
||||||
|
|
||||||
|
from app.notifications.webhook import send_webhook
|
||||||
|
result = await send_webhook(
|
||||||
|
url=url,
|
||||||
|
payload=_build_notification_payload(log.event_type, payload),
|
||||||
|
event_type=log.event_type,
|
||||||
|
webhook_secret=webhook_secret_val,
|
||||||
|
)
|
||||||
|
http_status = result.get("http_status")
|
||||||
|
success = True
|
||||||
|
logger.info(
|
||||||
|
f"[dispatch_notification] Webhook inviato: "
|
||||||
|
f"HTTP {http_status} delivery={result.get('delivery_id')} "
|
||||||
|
f"canale={channel.name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif channel_type == "email":
|
||||||
|
smtp_host = config.get("smtp_host")
|
||||||
|
smtp_port = int(config.get("smtp_port", 465))
|
||||||
|
from_email = config.get("from_email")
|
||||||
|
to_email = config.get("to_email")
|
||||||
|
smtp_user = config.get("smtp_user") or from_email
|
||||||
|
use_tls = config.get("smtp_use_tls", True)
|
||||||
|
use_starttls = config.get("smtp_use_starttls", False)
|
||||||
|
from_name = config.get("from_name", "PEChub Notifiche")
|
||||||
|
smtp_password = secret.get("smtp_password", "")
|
||||||
|
|
||||||
|
if not smtp_host or not from_email or not to_email:
|
||||||
|
raise ValueError("Configurazione SMTP incompleta")
|
||||||
|
|
||||||
|
from app.notifications.email_smtp import send_email_notification
|
||||||
|
subject = _build_email_subject(log.event_type, payload)
|
||||||
|
await send_email_notification(
|
||||||
|
smtp_host=smtp_host,
|
||||||
|
smtp_port=smtp_port,
|
||||||
|
smtp_user=smtp_user,
|
||||||
|
smtp_password=smtp_password,
|
||||||
|
from_email=from_email,
|
||||||
|
to_email=to_email,
|
||||||
|
subject=subject,
|
||||||
|
body_text=notif_text,
|
||||||
|
body_html=None,
|
||||||
|
from_name=from_name,
|
||||||
|
use_tls=use_tls,
|
||||||
|
use_starttls=use_starttls,
|
||||||
|
)
|
||||||
|
http_status = 200
|
||||||
|
success = True
|
||||||
|
logger.info(
|
||||||
|
f"[dispatch_notification] Email inviata a {to_email} "
|
||||||
|
f"canale={channel.name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif channel_type == "whatsapp":
|
||||||
|
phone_number_id = config.get("phone_number_id")
|
||||||
|
to_phone = config.get("to_phone")
|
||||||
|
access_token = secret.get("access_token")
|
||||||
|
|
||||||
|
if not phone_number_id or not to_phone or not access_token:
|
||||||
|
raise ValueError("Configurazione WhatsApp incompleta")
|
||||||
|
|
||||||
|
from app.notifications.whatsapp import send_whatsapp_message
|
||||||
|
result = await send_whatsapp_message(
|
||||||
|
phone_number_id=phone_number_id,
|
||||||
|
to_phone=to_phone,
|
||||||
|
text=notif_text,
|
||||||
|
access_token=access_token,
|
||||||
|
)
|
||||||
|
http_status = result.get("http_status")
|
||||||
|
success = True
|
||||||
|
logger.info(
|
||||||
|
f"[dispatch_notification] WhatsApp inviato: "
|
||||||
|
f"message_id={result.get('message_id')} "
|
||||||
|
f"canale={channel.name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Tipo canale non supportato: {channel_type!r}")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
error_msg = str(exc)
|
||||||
|
logger.warning(
|
||||||
|
f"[dispatch_notification] Tentativo {current_attempt} fallito "
|
||||||
|
f"canale={channel.name!r} tipo={channel_type!r}: {error_msg}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Aggiorna stato log e canale ───────────────────────────────────────
|
||||||
|
|
||||||
|
if success:
|
||||||
|
log.status = "sent"
|
||||||
|
log.sent_at = datetime.now(UTC)
|
||||||
|
log.http_status = http_status
|
||||||
|
|
||||||
|
# Reset circuit breaker
|
||||||
|
channel.consecutive_failures = 0
|
||||||
|
channel.circuit_open_until = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"[dispatch_notification] Notifica {notification_log_id} INVIATA "
|
||||||
|
f"canale={channel.name!r} tipo={channel_type!r}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "sent",
|
||||||
|
"log_id": notification_log_id,
|
||||||
|
"channel": channel.name,
|
||||||
|
"channel_type": channel_type,
|
||||||
|
"attempt": current_attempt,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ── Retry o failure definitivo ────────────────────────────────────
|
||||||
|
log.last_error = error_msg
|
||||||
|
log.http_status = http_status
|
||||||
|
|
||||||
|
if current_attempt < log.max_attempts:
|
||||||
|
# Calcola delay backoff
|
||||||
|
delay_idx = min(current_attempt - 1, len(_RETRY_DELAYS) - 1)
|
||||||
|
delay_seconds = _RETRY_DELAYS[delay_idx]
|
||||||
|
next_retry = datetime.now(UTC) + timedelta(seconds=delay_seconds)
|
||||||
|
|
||||||
|
log.next_retry_at = next_retry
|
||||||
|
# Mantieni status "pending" per il retry
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Re-enqueue con backoff
|
||||||
|
if redis_client:
|
||||||
|
try:
|
||||||
|
await redis_client.enqueue_job(
|
||||||
|
"dispatch_notification",
|
||||||
|
notification_log_id,
|
||||||
|
_defer_by=timedelta(seconds=delay_seconds),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"[dispatch_notification] Retry tentativo "
|
||||||
|
f"{current_attempt + 1} schedulato in {delay_seconds}s "
|
||||||
|
f"per log {notification_log_id}"
|
||||||
|
)
|
||||||
|
except Exception as enqueue_err:
|
||||||
|
logger.error(
|
||||||
|
f"[dispatch_notification] Impossibile re-enqueue "
|
||||||
|
f"log {notification_log_id}: {enqueue_err}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "retry",
|
||||||
|
"log_id": notification_log_id,
|
||||||
|
"attempt": current_attempt,
|
||||||
|
"next_retry_in_seconds": delay_seconds,
|
||||||
|
"error": error_msg,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Tutti i tentativi esauriti → FAILED
|
||||||
|
log.status = "failed"
|
||||||
|
|
||||||
|
# Aggiorna circuit breaker canale
|
||||||
|
channel.consecutive_failures += 1
|
||||||
|
if channel.consecutive_failures >= _CIRCUIT_FAILURE_THRESHOLD:
|
||||||
|
channel.circuit_open_until = datetime.now(UTC) + _CIRCUIT_OPEN_DURATION
|
||||||
|
logger.warning(
|
||||||
|
f"[dispatch_notification] Circuit breaker aperto per "
|
||||||
|
f"canale {channel.name!r} "
|
||||||
|
f"({channel.consecutive_failures} fallimenti consecutivi)"
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.error(
|
||||||
|
f"[dispatch_notification] Notifica {notification_log_id} FALLITA "
|
||||||
|
f"definitivamente dopo {current_attempt} tentativi: {error_msg}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"log_id": notification_log_id,
|
||||||
|
"channel": channel.name,
|
||||||
|
"attempts": current_attempt,
|
||||||
|
"error": error_msg,
|
||||||
|
}
|
||||||
+2
-1
@@ -24,6 +24,7 @@ from arq.connections import RedisSettings
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.imap.pool import MailboxPool
|
from app.imap.pool import MailboxPool
|
||||||
|
from app.jobs.dispatch_notification import dispatch_notification
|
||||||
from app.jobs.send_pec import send_pec
|
from app.jobs.send_pec import send_pec
|
||||||
from app.jobs.sync_mailbox import sync_mailbox
|
from app.jobs.sync_mailbox import sync_mailbox
|
||||||
from app.smtp.receipt_watcher import watch_receipt
|
from app.smtp.receipt_watcher import watch_receipt
|
||||||
@@ -132,7 +133,7 @@ class WorkerSettings:
|
|||||||
"""Configurazione del worker arq."""
|
"""Configurazione del worker arq."""
|
||||||
|
|
||||||
# Funzioni/job registrati
|
# Funzioni/job registrati
|
||||||
functions = [sync_mailbox, send_pec, watch_receipt, health_check]
|
functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, health_check]
|
||||||
|
|
||||||
# Callbacks lifecycle
|
# Callbacks lifecycle
|
||||||
on_startup = on_startup
|
on_startup = on_startup
|
||||||
|
|||||||
+125
-1
@@ -22,7 +22,7 @@ from sqlalchemy import (
|
|||||||
ARRAY, BigInteger, Boolean, DateTime, Enum, ForeignKey,
|
ARRAY, BigInteger, Boolean, DateTime, Enum, ForeignKey,
|
||||||
Integer, String, Text, func,
|
Integer, String, Text, func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
@@ -202,3 +202,127 @@ class Attachment(Base):
|
|||||||
|
|
||||||
# Relazione inversa verso Message
|
# Relazione inversa verso Message
|
||||||
message: Mapped["Message"] = relationship("Message", back_populates="attachments")
|
message: Mapped["Message"] = relationship("Message", back_populates="attachments")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Modelli Notifiche ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
NotifChannelType = Enum(
|
||||||
|
"webhook", "email", "telegram", "whatsapp",
|
||||||
|
name="notification_channel_type",
|
||||||
|
create_type=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
NotifStatus = Enum(
|
||||||
|
"pending", "sent", "failed", "skipped",
|
||||||
|
name="notification_status",
|
||||||
|
create_type=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannel(Base):
|
||||||
|
"""
|
||||||
|
Canale di notifica configurato da un tenant.
|
||||||
|
Corrisponde alla tabella `notification_channels` nel DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "notification_channels"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
channel_type: Mapped[str] = mapped_column(NotifChannelType, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
config_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
circuit_open_until: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
rules: Mapped[list["NotificationRule"]] = relationship(
|
||||||
|
"NotificationRule", back_populates="channel", lazy="select"
|
||||||
|
)
|
||||||
|
logs: Mapped[list["NotificationLog"]] = relationship(
|
||||||
|
"NotificationLog", back_populates="channel", lazy="select"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationRule(Base):
|
||||||
|
"""
|
||||||
|
Regola evento PEC -> canale di notifica.
|
||||||
|
Corrisponde alla tabella `notification_rules` nel DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "notification_rules"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
|
channel_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("notification_channels.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
filter: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
channel: Mapped["NotificationChannel"] = relationship(
|
||||||
|
"NotificationChannel", back_populates="rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationLog(Base):
|
||||||
|
"""
|
||||||
|
Log di ogni tentativo di notifica con retry e circuit breaker.
|
||||||
|
Corrisponde alla tabella `notification_log` nel DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "notification_log"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
|
channel_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("notification_channels.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
rule_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("notification_rules.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
event_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(NotifStatus, nullable=False, default="pending")
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||||
|
next_retry_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
channel: Mapped["NotificationChannel"] = relationship(
|
||||||
|
"NotificationChannel", back_populates="logs"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Senders notifiche multi-canale per il worker
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Email SMTP sender (worker) – invio notifiche via aiosmtplib.
|
||||||
|
|
||||||
|
Copia del sender backend: i due container sono separati.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
import aiosmtplib
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSMTPError(Exception):
|
||||||
|
def __init__(self, message: str, smtp_code: int | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.smtp_code = smtp_code
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email_notification(
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
smtp_user: str,
|
||||||
|
smtp_password: str,
|
||||||
|
from_email: str,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
body_html: str | None = None,
|
||||||
|
from_name: str = "PEChub Notifiche",
|
||||||
|
use_tls: bool = True,
|
||||||
|
use_starttls: bool = False,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Invia un'email di notifica via SMTP.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EmailSMTPError: in caso di errori di autenticazione, connessione o invio
|
||||||
|
"""
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
|
||||||
|
msg["To"] = to_email
|
||||||
|
msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||||
|
msg["X-Mailer"] = "PEChub/1.0"
|
||||||
|
|
||||||
|
msg.attach(MIMEText(body_text, "plain", "utf-8"))
|
||||||
|
if body_html:
|
||||||
|
msg.attach(MIMEText(body_html, "html", "utf-8"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await aiosmtplib.send(
|
||||||
|
msg,
|
||||||
|
hostname=smtp_host,
|
||||||
|
port=smtp_port,
|
||||||
|
username=smtp_user,
|
||||||
|
password=smtp_password,
|
||||||
|
use_tls=use_tls,
|
||||||
|
start_tls=use_starttls,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except aiosmtplib.SMTPAuthenticationError as exc:
|
||||||
|
raise EmailSMTPError(
|
||||||
|
f"Autenticazione SMTP fallita: {exc}", smtp_code=535
|
||||||
|
) from exc
|
||||||
|
except aiosmtplib.SMTPConnectError as exc:
|
||||||
|
raise EmailSMTPError(
|
||||||
|
f"Connessione SMTP fallita a {smtp_host}:{smtp_port}: {exc}"
|
||||||
|
) from exc
|
||||||
|
except aiosmtplib.SMTPException as exc:
|
||||||
|
raise EmailSMTPError(f"Errore SMTP: {exc}") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise EmailSMTPError(f"Errore invio email: {exc}") from exc
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Telegram Bot API – invio messaggi via sendMessage (worker).
|
||||||
|
|
||||||
|
Copia del sender backend: i due container sono separati e non
|
||||||
|
possono condividere package, quindi il codice e' duplicato.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
TELEGRAM_API_BASE = "https://api.telegram.org"
|
||||||
|
DEFAULT_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramError(Exception):
|
||||||
|
def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.http_status = http_status
|
||||||
|
self.api_code = api_code
|
||||||
|
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
bot_token: str,
|
||||||
|
chat_id: str,
|
||||||
|
text: str,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
disable_web_page_preview: bool = True,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Invia un messaggio a un canale/gruppo/utente Telegram.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con il risultato della API Telegram (result.message_id, ecc.)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TelegramError: in caso di errore HTTP o risposta API non-ok
|
||||||
|
"""
|
||||||
|
url = f"{TELEGRAM_API_BASE}/bot{bot_token}/sendMessage"
|
||||||
|
payload: dict = {"chat_id": chat_id, "text": text}
|
||||||
|
if parse_mode:
|
||||||
|
payload["parse_mode"] = parse_mode
|
||||||
|
if disable_web_page_preview:
|
||||||
|
payload["link_preview_options"] = {"is_disabled": True}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise TelegramError(f"Timeout Telegram ({timeout}s)") from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise TelegramError(f"Errore di rete Telegram: {exc}") from exc
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise TelegramError(
|
||||||
|
f"Telegram API HTTP {response.status_code}: {response.text[:200]}",
|
||||||
|
http_status=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if not data.get("ok"):
|
||||||
|
api_code = data.get("error_code")
|
||||||
|
description = data.get("description", "Errore sconosciuto")
|
||||||
|
raise TelegramError(
|
||||||
|
f"Telegram API error {api_code}: {description}",
|
||||||
|
http_status=response.status_code,
|
||||||
|
api_code=api_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.get("result", {})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Webhook sender (worker) – POST HTTP con firma HMAC-SHA256.
|
||||||
|
|
||||||
|
Copia del sender backend: i due container sono separati.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import uuid as uuid_mod
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookError(Exception):
|
||||||
|
def __init__(self, message: str, http_status: int | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.http_status = http_status
|
||||||
|
|
||||||
|
|
||||||
|
async def send_webhook(
|
||||||
|
url: str,
|
||||||
|
payload: dict,
|
||||||
|
event_type: str = "new_message",
|
||||||
|
webhook_secret: str | None = None,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Invia un payload JSON a un webhook URL.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con http_status, delivery_id
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WebhookError: in caso di timeout, errore di rete o HTTP >= 400
|
||||||
|
"""
|
||||||
|
body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
||||||
|
delivery_id = str(uuid_mod.uuid4())
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-PEChub-Event": event_type,
|
||||||
|
"X-Delivery": delivery_id,
|
||||||
|
"User-Agent": "PEChub-Webhook/1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
if webhook_secret:
|
||||||
|
sig = hmac.new(
|
||||||
|
webhook_secret.encode("utf-8"),
|
||||||
|
body,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
headers["X-Hub-Signature-256"] = f"sha256={sig}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(url, content=body, headers=headers)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise WebhookError(f"Timeout webhook dopo {timeout}s") from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise WebhookError(f"Errore di rete webhook: {exc}") from exc
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise WebhookError(
|
||||||
|
f"Webhook HTTP {response.status_code}: {response.text[:200]}",
|
||||||
|
http_status=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"http_status": response.status_code,
|
||||||
|
"delivery_id": delivery_id,
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
WhatsApp sender (worker) – Meta Cloud API v18.
|
||||||
|
|
||||||
|
Copia del sender backend: i due container sono separati.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
META_GRAPH_API_URL = "https://graph.facebook.com/v18.0"
|
||||||
|
DEFAULT_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppError(Exception):
|
||||||
|
def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.http_status = http_status
|
||||||
|
self.api_code = api_code
|
||||||
|
|
||||||
|
|
||||||
|
async def send_whatsapp_message(
|
||||||
|
phone_number_id: str,
|
||||||
|
to_phone: str,
|
||||||
|
text: str,
|
||||||
|
access_token: str,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Invia un messaggio di testo WhatsApp via Meta Cloud API.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WhatsAppError: in caso di errore HTTP o risposta API non-ok
|
||||||
|
"""
|
||||||
|
url = f"{META_GRAPH_API_URL}/{phone_number_id}/messages"
|
||||||
|
payload = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"to": to_phone.replace(" ", "").replace("-", ""),
|
||||||
|
"type": "text",
|
||||||
|
"text": {"body": text},
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(url, json=payload, headers=headers)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise WhatsAppError(f"Timeout WhatsApp API dopo {timeout}s") from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise WhatsAppError(f"Errore di rete WhatsApp: {exc}") from exc
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise WhatsAppError("Token Meta non valido o scaduto", http_status=401)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
try:
|
||||||
|
err_data = response.json()
|
||||||
|
err_msg = err_data.get("error", {}).get("message", response.text[:200])
|
||||||
|
err_code = err_data.get("error", {}).get("code")
|
||||||
|
except Exception:
|
||||||
|
err_msg = response.text[:200]
|
||||||
|
err_code = None
|
||||||
|
raise WhatsAppError(
|
||||||
|
f"Meta API errore HTTP {response.status_code}: {err_msg}",
|
||||||
|
http_status=response.status_code,
|
||||||
|
api_code=err_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
messages = data.get("messages", [])
|
||||||
|
message_id = messages[0].get("id") if messages else None
|
||||||
|
return {"message_id": message_id, "http_status": response.status_code}
|
||||||
Reference in New Issue
Block a user