"""Email service for sending alerts and reports. This module provides email functionality for the AlpineBits application, including error alerts and daily reports. """ import asyncio import smtplib import ssl from datetime import datetime from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import Any from pydantic import EmailStr, Field, field_validator from .logging_config import get_logger _LOGGER = get_logger(__name__) class EmailConfig: """Configuration for email service. Attributes: smtp_host: SMTP server hostname smtp_port: SMTP server port smtp_username: SMTP authentication username smtp_password: SMTP authentication password use_tls: Use STARTTLS for encryption use_ssl: Use SSL/TLS from the start from_address: Sender email address from_name: Sender display name timeout: Connection timeout in seconds """ def __init__(self, config: dict[str, Any]): """Initialize email configuration from config dict. Args: config: Email configuration dictionary """ smtp_config = config.get("smtp", {}) self.smtp_host: str = smtp_config.get("host", "localhost") self.smtp_port: int = smtp_config.get("port", 587) self.smtp_username: str | None = smtp_config.get("username") self.smtp_password: str | None = smtp_config.get("password") self.use_tls: bool = smtp_config.get("use_tls", True) self.use_ssl: bool = smtp_config.get("use_ssl", False) self.from_address: str = config.get("from_address", "noreply@example.com") self.from_name: str = config.get("from_name", "AlpineBits Server") self.timeout: int = config.get("timeout", 10) # Validate configuration if self.use_tls and self.use_ssl: msg = "Cannot use both TLS and SSL" raise ValueError(msg) class EmailService: """Service for sending emails via SMTP. This service handles sending both plain text and HTML emails, with support for TLS/SSL encryption and authentication. """ def __init__(self, config: EmailConfig): """Initialize email service. Args: config: Email configuration """ self.config = config self._executor = None # Lazy-initialized thread pool for blocking SMTP async def send_email( self, recipients: list[str], subject: str, body: str, html_body: str | None = None, ) -> bool: """Send an email to recipients. Args: recipients: List of recipient email addresses subject: Email subject line body: Plain text email body html_body: Optional HTML email body Returns: True if email was sent successfully, False otherwise """ if not recipients: _LOGGER.warning("No recipients specified for email: %s", subject) return False try: # Build message msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = f"{self.config.from_name} <{self.config.from_address}>" msg["To"] = ", ".join(recipients) msg["Date"] = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z") # Attach plain text body msg.attach(MIMEText(body, "plain")) # Attach HTML body if provided if html_body: msg.attach(MIMEText(html_body, "html")) # Send email in thread pool (SMTP is blocking) loop = asyncio.get_event_loop() await loop.run_in_executor(None, self._send_smtp, msg, recipients) _LOGGER.info("Email sent successfully to %s: %s", recipients, subject) return True except Exception: _LOGGER.exception("Failed to send email to %s: %s", recipients, subject) return False def _send_smtp(self, msg: MIMEMultipart, recipients: list[str]) -> None: """Send email via SMTP (blocking operation). Args: msg: Email message to send recipients: List of recipient addresses Raises: Exception: If email sending fails """ if self.config.use_ssl: # Connect with SSL from the start context = ssl.create_default_context() with smtplib.SMTP_SSL( self.config.smtp_host, self.config.smtp_port, timeout=self.config.timeout, context=context, ) as server: if self.config.smtp_username and self.config.smtp_password: server.login(self.config.smtp_username, self.config.smtp_password) server.send_message(msg, self.config.from_address, recipients) else: # Connect and optionally upgrade to TLS with smtplib.SMTP( self.config.smtp_host, self.config.smtp_port, timeout=self.config.timeout, ) as server: if self.config.use_tls: context = ssl.create_default_context() server.starttls(context=context) if self.config.smtp_username and self.config.smtp_password: server.login(self.config.smtp_username, self.config.smtp_password) server.send_message(msg, self.config.from_address, recipients) async def send_alert( self, recipients: list[str], subject: str, body: str, ) -> bool: """Send an alert email (convenience method). Args: recipients: List of recipient email addresses subject: Email subject line body: Email body text Returns: True if email was sent successfully, False otherwise """ return await self.send_email(recipients, subject, body) async def send_daily_report( self, recipients: list[str], stats: dict[str, Any], errors: list[dict[str, Any]] | None = None, ) -> bool: """Send a daily report email. Args: recipients: List of recipient email addresses stats: Dictionary containing statistics to include in report errors: Optional list of errors to include Returns: True if email was sent successfully, False otherwise """ date_str = datetime.now().strftime("%Y-%m-%d") subject = f"AlpineBits Daily Report - {date_str}" # Build plain text body body = f"AlpineBits Daily Report for {date_str}\n" body += "=" * 60 + "\n\n" # Add statistics if stats: body += "Statistics:\n" body += "-" * 60 + "\n" for key, value in stats.items(): body += f" {key}: {value}\n" body += "\n" # Add errors if present if errors: body += f"Errors ({len(errors)}):\n" body += "-" * 60 + "\n" for error in errors[:20]: # Limit to 20 most recent errors timestamp = error.get("timestamp", "Unknown") level = error.get("level", "ERROR") message = error.get("message", "No message") body += f" [{timestamp}] {level}: {message}\n" if len(errors) > 20: body += f" ... and {len(errors) - 20} more errors\n" body += "\n" body += "-" * 60 + "\n" body += "Generated by AlpineBits Server\n" # Build HTML body for better formatting html_body = self._build_daily_report_html(date_str, stats, errors) return await self.send_email(recipients, subject, body, html_body) def _build_daily_report_html( self, date_str: str, stats: dict[str, Any], errors: list[dict[str, Any]] | None, ) -> str: """Build HTML version of daily report. Args: date_str: Date string for the report stats: Statistics dictionary errors: Optional list of errors Returns: HTML string for the email body """ html = f"""

AlpineBits Daily Report

Date: {date_str}

""" # Add statistics table if stats: html += """

Statistics

""" for key, value in stats.items(): html += f""" """ html += "
Metric Value
{key} {value}
" # Add errors table if errors: html += f"""

Errors ({len(errors)})

""" for error in errors[:20]: # Limit to 20 most recent timestamp = error.get("timestamp", "Unknown") level = error.get("level", "ERROR") message = error.get("message", "No message") css_class = "error" if level == "ERROR" or level == "CRITICAL" else "warning" html += f""" """ if len(errors) > 20: html += f""" """ html += "
Time Level Message
{timestamp} {level} {message}
... and {len(errors) - 20} more errors
" html += """ """ return html def create_email_service(config: dict[str, Any]) -> EmailService | None: """Create an email service from configuration. Args: config: Full application configuration dictionary Returns: EmailService instance if email is configured, None otherwise """ email_config = config.get("email") if not email_config: _LOGGER.info("Email not configured, email service disabled") return None try: email_cfg = EmailConfig(email_config) service = EmailService(email_cfg) _LOGGER.info("Email service initialized: %s:%s", email_cfg.smtp_host, email_cfg.smtp_port) return service except Exception: _LOGGER.exception("Failed to initialize email service") return None