"""
Notification Channels - Email (SMTP) and Telegram Bot
Settings are loaded from database (panel_settings table)
"""
import asyncio
import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.image import MIMEImage
from typing import Optional, Dict, Any, Tuple
from datetime import datetime
from loguru import logger
try:
import httpx
except ImportError:
httpx = None
def get_local_time() -> str:
"""Get current local time formatted string"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def get_notification_setting(key: str, default: str = None) -> Optional[str]:
"""Get notification setting from database"""
from ..db import SessionLocal
from ..models import PanelSettings
db = SessionLocal()
try:
setting = db.query(PanelSettings).filter(PanelSettings.key == key).first()
return setting.value if setting else default
finally:
db.close()
def get_panel_name() -> str:
"""Get panel name from database"""
return get_notification_setting('panel_name', 'PVEmanager') or 'PVEmanager'
def get_logo_base64() -> Optional[str]:
"""Get logo as base64 for email embedding"""
import base64
import os
logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'img', 'logo.png')
if os.path.exists(logo_path):
try:
with open(logo_path, 'rb') as f:
logo_data = f.read()
return base64.b64encode(logo_data).decode('utf-8')
except Exception as e:
logger.error(f"Failed to read logo: {e}")
return None
def get_logo_bytes() -> Optional[bytes]:
"""Get logo as bytes for email attachment"""
import os
logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'img', 'logo.png')
if os.path.exists(logo_path):
try:
with open(logo_path, 'rb') as f:
return f.read()
except Exception as e:
logger.error(f"Failed to read logo: {e}")
return None
class EmailChannel:
"""SMTP Email notification channel - settings from database"""
def __init__(self):
self._load_settings()
def _load_settings(self):
"""Load SMTP settings from database"""
self.smtp_host = get_notification_setting('smtp_host')
self.smtp_port = int(get_notification_setting('smtp_port', '587') or '587')
self.smtp_user = get_notification_setting('smtp_user')
self.smtp_password = get_notification_setting('smtp_password')
self.smtp_from = get_notification_setting('smtp_from') or self.smtp_user
self.smtp_tls = get_notification_setting('smtp_tls', 'true').lower() == 'true'
self.enabled = bool(self.smtp_host and self.smtp_user and self.smtp_password)
def reload_settings(self):
"""Reload settings from database"""
self._load_settings()
def is_configured(self) -> bool:
"""Check if SMTP is configured"""
self._load_settings() # Always reload to get latest settings
return self.enabled
async def send(
self,
to_email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
embedded_images: Optional[Dict[str, bytes]] = None
) -> bool:
"""Send email notification
Args:
to_email: Recipient email
subject: Email subject
body: Plain text body
html_body: HTML body (optional)
embedded_images: Dict of {cid: image_bytes} for embedding (optional)
"""
if not self.is_configured():
logger.warning("SMTP not configured, skipping email notification")
return False
try:
# Create message - use 'related' for embedded images
if embedded_images:
msg = MIMEMultipart("related")
msg_alt = MIMEMultipart("alternative")
msg.attach(msg_alt)
else:
msg = MIMEMultipart("alternative")
msg_alt = msg
msg["Subject"] = subject
msg["From"] = self.smtp_from
msg["To"] = to_email
# Add plain text
msg_alt.attach(MIMEText(body, "plain", "utf-8"))
# Add HTML if provided
if html_body:
msg_alt.attach(MIMEText(html_body, "html", "utf-8"))
# Attach embedded images
if embedded_images:
for cid, image_data in embedded_images.items():
img = MIMEImage(image_data)
img.add_header('Content-ID', f'<{cid}>')
img.add_header('Content-Disposition', 'inline', filename=f'{cid}.png')
msg.attach(img)
# Send in thread pool to not block async
# Use asyncio.to_thread for better compatibility with FastAPI
try:
await asyncio.wait_for(
asyncio.to_thread(self._send_sync, to_email, msg),
timeout=15.0 # 15 second timeout for entire operation
)
except asyncio.TimeoutError:
logger.error(f"Email sending to {to_email} timed out after 15 seconds")
return False
logger.info(f"Email notification sent to {to_email}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
def _send_sync(self, to_email: str, msg: MIMEMultipart):
"""Synchronous email sending with timeout"""
context = ssl.create_default_context()
timeout = 10 # 10 seconds timeout
# Port 465 always uses SSL (SMTPS)
# Port 587 uses STARTTLS (SMTP with upgrade)
# Port 25 uses plain SMTP (or STARTTLS if smtp_tls=True)
use_ssl = self.smtp_port == 465
logger.info(f"Sending email via {self.smtp_host}:{self.smtp_port} (SSL={use_ssl}, TLS={self.smtp_tls})")
if use_ssl:
# SSL connection (port 465 - SMTPS)
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=context, timeout=timeout) as server:
server.login(self.smtp_user, self.smtp_password)
server.sendmail(self.smtp_from, to_email, msg.as_string())
else:
# STARTTLS connection (port 587, 25, or custom)
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=timeout) as server:
if self.smtp_tls:
server.starttls(context=context)
server.login(self.smtp_user, self.smtp_password)
server.sendmail(self.smtp_from, to_email, msg.as_string())
def format_notification(self, notification: Dict[str, Any]) -> tuple:
"""Format notification for email - returns (subject, body, html_body, embedded_images)"""
level_emoji = {
"critical": "🔴",
"warning": "🟠",
"info": "🔵",
"success": "🟢"
}
level = notification.get("level", "info")
emoji = level_emoji.get(level, "🔵")
title = notification.get("title", "Notification")
message = notification.get("message", "")
source = notification.get("source", "system")
created_at = notification.get("created_at", "now")
# Получаем имя панели
panel_name = get_panel_name()
subject = f"{emoji} [{panel_name}] {title}"
body = f"""
{title}
{message}
Level: {level.upper()}
Source: {source}
Time: {created_at}
---
{panel_name}
""".strip()
# Получаем логотип для встраивания
logo_bytes = get_logo_bytes()
embedded_images = {}
if logo_bytes:
embedded_images['logo'] = logo_bytes
logo_html = f''
else:
logo_html = f'🖥️'
html_body = f"""