PyRunner / core /services /notification_service.py
Akoda35's picture
Upload 15 files
56b2689 verified
Raw
History Blame Contribute Delete
12.3 kB
"""
Notification service for sending email and webhook notifications.
This module handles sending notifications when script runs complete.
Supports email via SMTP or Resend, and webhook POST notifications.
"""
import ipaddress
import logging
from typing import TYPE_CHECKING
from urllib.parse import urlparse
import requests
from django.core.mail import EmailMultiAlternatives
from django.core.mail.backends.smtp import EmailBackend as SMTPBackend
from django.template.loader import render_to_string
from django.utils import timezone
from .encryption_service import EncryptionService, EncryptionError
if TYPE_CHECKING:
from core.models import Run, GlobalSettings
logger = logging.getLogger(__name__)
class NotificationService:
"""
Handles sending notifications via email and webhooks.
"""
WEBHOOK_TIMEOUT = 10 # seconds
@classmethod
def _is_safe_webhook_url(cls, url: str) -> bool:
"""
Validate webhook URL to prevent SSRF attacks.
Args:
url: The webhook URL to validate
Returns:
bool: True if URL is safe to use
"""
try:
parsed = urlparse(url)
# Require http or https scheme
if parsed.scheme not in ("http", "https"):
return False
hostname = parsed.hostname
if not hostname:
return False
# Block localhost variations
if hostname.lower() in ("localhost", "127.0.0.1", "::1", "0.0.0.0"):
return False
# Try to parse as IP and check if private/reserved
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
return False
except ValueError:
pass # It's a hostname, not an IP - allow it
return True
except Exception:
return False
@classmethod
def should_notify(cls, run: "Run") -> bool:
"""
Determine if a notification should be sent for this run.
Args:
run: The completed Run instance
Returns:
bool: True if notification should be sent
"""
from core.models import Script
script = run.script
notify_on = script.notify_on
if notify_on == Script.NotifyOn.NEVER:
return False
elif notify_on == Script.NotifyOn.SUCCESS:
return run.status == "success"
elif notify_on == Script.NotifyOn.FAILURE:
return run.status in ["failed", "timeout"]
elif notify_on == Script.NotifyOn.BOTH:
return run.is_finished
return False
@classmethod
def send_notification(cls, run: "Run") -> dict:
"""
Send all applicable notifications for a completed run.
Args:
run: The completed Run instance
Returns:
dict: Results of notification attempts
"""
results = {
"email_sent": False,
"email_error": None,
"webhook_sent": False,
"webhook_error": None,
}
if not cls.should_notify(run):
logger.debug(f"Skipping notification for run {run.id} - notify_on={run.script.notify_on}")
return results
# Send email notification
if cls._should_send_email(run):
try:
cls._send_email_notification(run)
results["email_sent"] = True
logger.info(f"Email notification sent for run {run.id}")
except Exception as e:
logger.error(f"Failed to send email notification for run {run.id}: {e}")
results["email_error"] = str(e)
# Send webhook notification
if run.script.notify_webhook_enabled and run.script.notify_webhook_url:
try:
cls._send_webhook_notification(run)
results["webhook_sent"] = True
logger.info(f"Webhook notification sent for run {run.id}")
except Exception as e:
logger.error(f"Failed to send webhook notification for run {run.id}: {e}")
results["webhook_error"] = str(e)
return results
@classmethod
def _should_send_email(cls, run: "Run") -> bool:
"""Check if email notification should be sent."""
from core.models import GlobalSettings
settings = GlobalSettings.get_settings()
if settings.email_backend == GlobalSettings.EmailBackend.DISABLED:
return False
# Check if there's a recipient email
recipient = run.script.notify_email or settings.default_notification_email
return bool(recipient)
@classmethod
def _get_email_backend(cls, settings: "GlobalSettings") -> SMTPBackend | None:
"""
Get the appropriate email backend based on settings.
Returns:
Email backend instance or None if disabled
"""
from core.models import GlobalSettings
if settings.email_backend == GlobalSettings.EmailBackend.SMTP:
password = ""
if settings.smtp_password_encrypted:
try:
password = EncryptionService.decrypt(settings.smtp_password_encrypted)
except EncryptionError as e:
logger.warning(
f"Failed to decrypt SMTP password: {e}. "
"Email notifications may fail due to missing credentials."
)
return SMTPBackend(
host=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=password,
use_tls=settings.smtp_use_tls,
fail_silently=False,
)
elif settings.email_backend == GlobalSettings.EmailBackend.RESEND:
# Use Resend's SMTP gateway
api_key = ""
if settings.resend_api_key_encrypted:
try:
api_key = EncryptionService.decrypt(settings.resend_api_key_encrypted)
except EncryptionError as e:
logger.warning(
f"Failed to decrypt Resend API key: {e}. "
"Email notifications may fail due to missing credentials."
)
return SMTPBackend(
host="smtp.resend.com",
port=587,
username="resend",
password=api_key,
use_tls=True,
fail_silently=False,
)
return None
@classmethod
def _send_email_notification(cls, run: "Run") -> None:
"""Send email notification for a run."""
from core.models import GlobalSettings
settings = GlobalSettings.get_settings()
backend = cls._get_email_backend(settings)
if not backend:
raise ValueError("Email backend not configured")
recipient = run.script.notify_email or settings.default_notification_email
if settings.email_backend == GlobalSettings.EmailBackend.SMTP:
from_email = settings.smtp_from_email
else:
from_email = settings.resend_from_email
if not recipient or not from_email:
raise ValueError("Missing email configuration (recipient or from address)")
# Render templates
context = cls._build_email_context(run)
status_display = run.status.upper()
subject = f"[PyRunner] {run.script.name} - {status_display}"
text_content = render_to_string("notifications/email/run_completed.txt", context)
html_content = render_to_string("notifications/email/run_completed.html", context)
# Send email
email = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=from_email,
to=[recipient],
connection=backend,
)
email.attach_alternative(html_content, "text/html")
email.send()
logger.info(f"Email notification sent for run {run.id} to {recipient}")
@classmethod
def _send_webhook_notification(cls, run: "Run") -> None:
"""Send webhook notification for a run."""
url = run.script.notify_webhook_url
# Validate URL to prevent SSRF attacks
if not cls._is_safe_webhook_url(url):
raise ValueError(
f"Webhook URL rejected: must be http/https and not target localhost or private networks"
)
payload = cls._build_webhook_payload(run)
response = requests.post(
url,
json=payload,
timeout=cls.WEBHOOK_TIMEOUT,
headers={"Content-Type": "application/json", "User-Agent": "PyRunner/1.0"},
)
logger.info(
f"Webhook notification sent for run {run.id} to {url} "
f"(status: {response.status_code})"
)
@classmethod
def _build_email_context(cls, run: "Run") -> dict:
"""Build context for email templates."""
# Calculate duration display
duration_display = "N/A"
if run.duration:
duration_secs = run.duration
if duration_secs < 60:
duration_display = f"{duration_secs:.1f}s"
else:
minutes = int(duration_secs // 60)
seconds = int(duration_secs % 60)
duration_display = f"{minutes}m {seconds}s"
return {
"run": run,
"script": run.script,
"status": run.status,
"duration": duration_display,
"error_excerpt": run.stderr[:500] if run.stderr else None,
}
@classmethod
def _build_webhook_payload(cls, run: "Run") -> dict:
"""Build JSON payload for webhook notification."""
return {
"event_type": "run_completed",
"script": {
"id": str(run.script.id),
"name": run.script.name,
},
"run": {
"id": str(run.id),
"status": run.status,
"exit_code": run.exit_code,
"duration_seconds": run.duration,
"trigger_type": run.trigger_type,
},
"timestamps": {
"started_at": run.started_at.isoformat() if run.started_at else None,
"ended_at": run.ended_at.isoformat() if run.ended_at else None,
},
"error": run.stderr[:1000] if run.stderr and run.status in ["failed", "timeout"] else None,
}
@classmethod
def send_test_email(cls, recipient_email: str) -> bool:
"""
Send a test email to verify configuration.
Args:
recipient_email: Email address to send test to
Returns:
bool: True if successful
Raises:
Exception: If sending fails
"""
from core.models import GlobalSettings
settings = GlobalSettings.get_settings()
backend = cls._get_email_backend(settings)
if not backend:
raise ValueError("Email backend not configured or disabled")
if settings.email_backend == GlobalSettings.EmailBackend.SMTP:
from_email = settings.smtp_from_email
else:
from_email = settings.resend_from_email
if not from_email:
raise ValueError("From email address not configured")
subject = "[PyRunner] Test Email"
text_content = (
"This is a test email from PyRunner.\n\n"
"If you receive this, your email configuration is working correctly."
)
html_content = render_to_string(
"notifications/email/test_email.html",
{"timestamp": timezone.now()},
)
email = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=from_email,
to=[recipient_email],
connection=backend,
)
email.attach_alternative(html_content, "text/html")
email.send()
logger.info(f"Test email sent to {recipient_email}")
return True