374 lines
12 KiB
Python
374 lines
12 KiB
Python
"""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 concurrent.futures import ThreadPoolExecutor
|
|
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
|
|
# Create dedicated thread pool for SMTP operations (max 2 threads is enough for email)
|
|
# This prevents issues with default executor in multi-process environments
|
|
self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="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 dedicated thread pool (SMTP is blocking)
|
|
loop = asyncio.get_event_loop()
|
|
await loop.run_in_executor(self._executor, 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"""
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; }}
|
|
h1 {{ color: #333; }}
|
|
h2 {{ color: #666; margin-top: 20px; }}
|
|
table {{ border-collapse: collapse; width: 100%; }}
|
|
th, td {{ text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }}
|
|
th {{ background-color: #f2f2f2; }}
|
|
.error {{ color: #d32f2f; }}
|
|
.warning {{ color: #f57c00; }}
|
|
.footer {{ margin-top: 30px; color: #999; font-size: 12px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>AlpineBits Daily Report</h1>
|
|
<p><strong>Date:</strong> {date_str}</p>
|
|
"""
|
|
|
|
# Add statistics table
|
|
if stats:
|
|
html += """
|
|
<h2>Statistics</h2>
|
|
<table>
|
|
<tr>
|
|
<th>Metric</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
"""
|
|
for key, value in stats.items():
|
|
html += f"""
|
|
<tr>
|
|
<td>{key}</td>
|
|
<td>{value}</td>
|
|
</tr>
|
|
"""
|
|
html += "</table>"
|
|
|
|
# Add errors table
|
|
if errors:
|
|
html += f"""
|
|
<h2>Errors ({len(errors)})</h2>
|
|
<table>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Level</th>
|
|
<th>Message</th>
|
|
</tr>
|
|
"""
|
|
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"""
|
|
<tr>
|
|
<td>{timestamp}</td>
|
|
<td class="{css_class}">{level}</td>
|
|
<td>{message}</td>
|
|
</tr>
|
|
"""
|
|
if len(errors) > 20:
|
|
html += f"""
|
|
<tr>
|
|
<td colspan="3"><em>... and {len(errors) - 20} more errors</em></td>
|
|
</tr>
|
|
"""
|
|
html += "</table>"
|
|
|
|
html += """
|
|
<div class="footer">
|
|
<p>Generated by AlpineBits Server</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return html
|
|
|
|
def shutdown(self) -> None:
|
|
"""Shutdown the email service and clean up thread pool.
|
|
|
|
This should be called during application shutdown to ensure
|
|
proper cleanup of the thread pool executor.
|
|
"""
|
|
if self._executor:
|
|
_LOGGER.info("Shutting down email service thread pool")
|
|
self._executor.shutdown(wait=True, cancel_futures=False)
|
|
_LOGGER.info("Email service thread pool shut down complete")
|
|
|
|
|
|
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
|