Fix notification System

This commit is contained in:
2026-03-27 15:13:14 +01:00
parent a3247a69b6
commit e390d344ff
13 changed files with 1692 additions and 46 deletions
+161
View File
@@ -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,
)
+125
View File
@@ -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,
)
+125
View File
@@ -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,
)