Added email monitoring
This commit is contained in:
@@ -32,6 +32,7 @@ from .customer_service import CustomerService
|
||||
from .db import Base, get_database_url
|
||||
from .db import Customer as DBCustomer
|
||||
from .db import Reservation as DBReservation
|
||||
from .email_service import create_email_service
|
||||
from .logging_config import get_logger, setup_logging
|
||||
from .rate_limit import (
|
||||
BURST_RATE_LIMIT,
|
||||
@@ -185,8 +186,14 @@ async def lifespan(app: FastAPI):
|
||||
_LOGGER.exception("Failed to load config: ")
|
||||
config = {}
|
||||
|
||||
# Setup logging from config
|
||||
setup_logging(config)
|
||||
# Get event loop for email monitoring
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Initialize email service (before logging setup so it can be used by handlers)
|
||||
email_service = create_email_service(config)
|
||||
|
||||
# Setup logging from config with email monitoring
|
||||
email_handler, report_scheduler = setup_logging(config, email_service, loop)
|
||||
_LOGGER.info("Application startup initiated")
|
||||
|
||||
DATABASE_URL = get_database_url(config)
|
||||
@@ -198,6 +205,9 @@ async def lifespan(app: FastAPI):
|
||||
app.state.config = config
|
||||
app.state.alpine_bits_server = AlpineBitsServer(config)
|
||||
app.state.event_dispatcher = event_dispatcher
|
||||
app.state.email_service = email_service
|
||||
app.state.email_handler = email_handler
|
||||
app.state.report_scheduler = report_scheduler
|
||||
|
||||
# Register push listeners for hotels with push_endpoint
|
||||
for hotel in config.get("alpine_bits_auth", []):
|
||||
@@ -235,10 +245,31 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
_LOGGER.info("All existing customers already have hashed data")
|
||||
|
||||
# Start daily report scheduler if enabled
|
||||
if report_scheduler:
|
||||
report_scheduler.start()
|
||||
_LOGGER.info("Daily report scheduler started")
|
||||
|
||||
_LOGGER.info("Application startup complete")
|
||||
|
||||
yield
|
||||
|
||||
# Optional: Dispose engine on shutdown
|
||||
# Cleanup on shutdown
|
||||
_LOGGER.info("Application shutdown initiated")
|
||||
|
||||
# Stop daily report scheduler
|
||||
if report_scheduler:
|
||||
report_scheduler.stop()
|
||||
_LOGGER.info("Daily report scheduler stopped")
|
||||
|
||||
# Close email alert handler (flush any remaining errors)
|
||||
if email_handler:
|
||||
email_handler.close()
|
||||
_LOGGER.info("Email alert handler closed")
|
||||
|
||||
# Dispose engine
|
||||
await engine.dispose()
|
||||
_LOGGER.info("Application shutdown complete")
|
||||
|
||||
|
||||
async def get_async_session(request: Request):
|
||||
|
||||
@@ -6,9 +6,12 @@ from annotatedyaml.loader import load_yaml as load_annotated_yaml
|
||||
from voluptuous import (
|
||||
PREVENT_EXTRA,
|
||||
All,
|
||||
Boolean,
|
||||
In,
|
||||
Length,
|
||||
MultipleInvalid,
|
||||
Optional,
|
||||
Range,
|
||||
Required,
|
||||
Schema,
|
||||
)
|
||||
@@ -82,12 +85,74 @@ hotel_auth_schema = Schema(
|
||||
|
||||
basic_auth_schema = Schema(All([hotel_auth_schema], Length(min=1)))
|
||||
|
||||
# Email SMTP configuration schema
|
||||
smtp_schema = Schema(
|
||||
{
|
||||
Required("host", default="localhost"): str,
|
||||
Required("port", default=587): Range(min=1, max=65535),
|
||||
Optional("username"): str,
|
||||
Optional("password"): str,
|
||||
Required("use_tls", default=True): Boolean(),
|
||||
Required("use_ssl", default=False): Boolean(),
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
# Email daily report configuration schema
|
||||
daily_report_schema = Schema(
|
||||
{
|
||||
Required("enabled", default=False): Boolean(),
|
||||
Optional("recipients", default=[]): [str],
|
||||
Required("send_time", default="08:00"): str,
|
||||
Required("include_stats", default=True): Boolean(),
|
||||
Required("include_errors", default=True): Boolean(),
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
# Email error alerts configuration schema
|
||||
error_alerts_schema = Schema(
|
||||
{
|
||||
Required("enabled", default=False): Boolean(),
|
||||
Optional("recipients", default=[]): [str],
|
||||
Required("error_threshold", default=5): Range(min=1),
|
||||
Required("buffer_minutes", default=15): Range(min=1),
|
||||
Required("cooldown_minutes", default=15): Range(min=0),
|
||||
Required("log_levels", default=["ERROR", "CRITICAL"]): [
|
||||
In(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
|
||||
],
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
# Email monitoring configuration schema
|
||||
monitoring_schema = Schema(
|
||||
{
|
||||
Optional("daily_report", default={}): daily_report_schema,
|
||||
Optional("error_alerts", default={}): error_alerts_schema,
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
# Complete email configuration schema
|
||||
email_schema = Schema(
|
||||
{
|
||||
Optional("smtp", default={}): smtp_schema,
|
||||
Required("from_address", default="noreply@example.com"): str,
|
||||
Required("from_name", default="AlpineBits Server"): str,
|
||||
Optional("timeout", default=10): Range(min=1, max=300),
|
||||
Optional("monitoring", default={}): monitoring_schema,
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
config_schema = Schema(
|
||||
{
|
||||
Required(CONF_DATABASE): database_schema,
|
||||
Required(CONF_ALPINE_BITS_AUTH): basic_auth_schema,
|
||||
Required(CONF_SERVER): server_info,
|
||||
Required(CONF_LOGGING): logger_schema,
|
||||
Optional("email"): email_schema, # Email is optional
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
442
src/alpine_bits_python/email_monitoring.py
Normal file
442
src/alpine_bits_python/email_monitoring.py
Normal file
@@ -0,0 +1,442 @@
|
||||
"""Email monitoring and alerting through logging integration.
|
||||
|
||||
This module provides a custom logging handler that accumulates errors and sends
|
||||
email alerts based on configurable thresholds and time windows.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from .email_service import EmailService
|
||||
from .logging_config import get_logger
|
||||
|
||||
_LOGGER = get_logger(__name__)
|
||||
|
||||
|
||||
class ErrorRecord:
|
||||
"""Represents a single error log record for monitoring.
|
||||
|
||||
Attributes:
|
||||
timestamp: When the error occurred
|
||||
level: Log level (ERROR, CRITICAL, etc.)
|
||||
logger_name: Name of the logger that generated the error
|
||||
message: The error message
|
||||
exception: Exception info if available
|
||||
module: Module where error occurred
|
||||
line_no: Line number where error occurred
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, record: logging.LogRecord):
|
||||
"""Initialize from a logging.LogRecord.
|
||||
|
||||
Args:
|
||||
record: The logging record to wrap
|
||||
|
||||
"""
|
||||
self.timestamp = datetime.fromtimestamp(record.created)
|
||||
self.level = record.levelname
|
||||
self.logger_name = record.name
|
||||
self.message = record.getMessage()
|
||||
self.exception = record.exc_text if record.exc_info else None
|
||||
self.module = record.module
|
||||
self.line_no = record.lineno
|
||||
self.pathname = record.pathname
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary format.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the error
|
||||
|
||||
"""
|
||||
return {
|
||||
"timestamp": self.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"level": self.level,
|
||||
"logger_name": self.logger_name,
|
||||
"message": self.message,
|
||||
"exception": self.exception,
|
||||
"module": self.module,
|
||||
"line_no": self.line_no,
|
||||
"pathname": self.pathname,
|
||||
}
|
||||
|
||||
def format_plain_text(self) -> str:
|
||||
"""Format error as plain text for email.
|
||||
|
||||
Returns:
|
||||
Formatted plain text string
|
||||
|
||||
"""
|
||||
text = f"[{self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}] {self.level}: {self.message}\n"
|
||||
text += f" Module: {self.module}:{self.line_no} ({self.logger_name})\n"
|
||||
if self.exception:
|
||||
text += f" Exception:\n{self.exception}\n"
|
||||
return text
|
||||
|
||||
|
||||
class EmailAlertHandler(logging.Handler):
|
||||
"""Custom logging handler that sends email alerts for errors.
|
||||
|
||||
This handler uses a hybrid approach:
|
||||
- Accumulates errors in a buffer
|
||||
- Sends immediately if error threshold is reached
|
||||
- Otherwise sends after buffer duration expires
|
||||
- Always sends buffered errors (no minimum threshold for time-based flush)
|
||||
- Implements cooldown to prevent alert spam
|
||||
|
||||
The handler is thread-safe and works with asyncio event loops.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
email_service: EmailService,
|
||||
config: dict[str, Any],
|
||||
loop: asyncio.AbstractEventLoop | None = None,
|
||||
):
|
||||
"""Initialize the email alert handler.
|
||||
|
||||
Args:
|
||||
email_service: Email service instance for sending alerts
|
||||
config: Configuration dictionary for error alerts
|
||||
loop: Asyncio event loop (will use current loop if not provided)
|
||||
|
||||
"""
|
||||
super().__init__()
|
||||
self.email_service = email_service
|
||||
self.config = config
|
||||
self.loop = loop # Will be set when first error occurs if not provided
|
||||
|
||||
# Configuration
|
||||
self.recipients = config.get("recipients", [])
|
||||
self.error_threshold = config.get("error_threshold", 5)
|
||||
self.buffer_minutes = config.get("buffer_minutes", 15)
|
||||
self.cooldown_minutes = config.get("cooldown_minutes", 15)
|
||||
self.log_levels = config.get("log_levels", ["ERROR", "CRITICAL"])
|
||||
|
||||
# State
|
||||
self.error_buffer: deque[ErrorRecord] = deque()
|
||||
self.last_sent = datetime.min # Last time we sent an alert
|
||||
self._flush_task: asyncio.Task | None = None
|
||||
self._lock = threading.Lock() # Thread-safe for multi-threaded logging
|
||||
|
||||
_LOGGER.info(
|
||||
"EmailAlertHandler initialized: threshold=%d, buffer=%dmin, cooldown=%dmin",
|
||||
self.error_threshold,
|
||||
self.buffer_minutes,
|
||||
self.cooldown_minutes,
|
||||
)
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Handle a log record.
|
||||
|
||||
This is called automatically by the logging system when an error is logged.
|
||||
It's important that this method is fast and doesn't block.
|
||||
|
||||
Args:
|
||||
record: The log record to handle
|
||||
|
||||
"""
|
||||
# Only handle configured log levels
|
||||
if record.levelname not in self.log_levels:
|
||||
return
|
||||
|
||||
try:
|
||||
# Ensure we have an event loop
|
||||
if self.loop is None:
|
||||
try:
|
||||
self.loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop, we'll need to handle this differently
|
||||
_LOGGER.warning("No asyncio event loop available for email alerts")
|
||||
return
|
||||
|
||||
# Add error to buffer (thread-safe)
|
||||
with self._lock:
|
||||
error_record = ErrorRecord(record)
|
||||
self.error_buffer.append(error_record)
|
||||
buffer_size = len(self.error_buffer)
|
||||
|
||||
# Determine if we should send immediately
|
||||
should_send_immediately = buffer_size >= self.error_threshold
|
||||
|
||||
if should_send_immediately:
|
||||
# Cancel any pending flush task
|
||||
if self._flush_task and not self._flush_task.done():
|
||||
self._flush_task.cancel()
|
||||
|
||||
# Schedule immediate flush
|
||||
self._flush_task = asyncio.run_coroutine_threadsafe(
|
||||
self._flush_buffer(immediate=True),
|
||||
self.loop,
|
||||
)
|
||||
else:
|
||||
# Schedule delayed flush if not already scheduled
|
||||
if not self._flush_task or self._flush_task.done():
|
||||
self._flush_task = asyncio.run_coroutine_threadsafe(
|
||||
self._schedule_delayed_flush(),
|
||||
self.loop,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# Never let the handler crash - just log and continue
|
||||
_LOGGER.exception("Error in EmailAlertHandler.emit")
|
||||
|
||||
async def _schedule_delayed_flush(self) -> None:
|
||||
"""Schedule a delayed buffer flush after buffer duration."""
|
||||
await asyncio.sleep(self.buffer_minutes * 60)
|
||||
await self._flush_buffer(immediate=False)
|
||||
|
||||
async def _flush_buffer(self, *, immediate: bool) -> None:
|
||||
"""Flush the error buffer and send email alert.
|
||||
|
||||
Args:
|
||||
immediate: Whether this is an immediate flush (threshold hit)
|
||||
|
||||
"""
|
||||
# Check cooldown period
|
||||
now = datetime.now()
|
||||
time_since_last = (now - self.last_sent).total_seconds() / 60
|
||||
|
||||
if time_since_last < self.cooldown_minutes:
|
||||
_LOGGER.info(
|
||||
"Alert cooldown active (%.1f min remaining), buffering errors",
|
||||
self.cooldown_minutes - time_since_last,
|
||||
)
|
||||
# Don't clear buffer - let errors accumulate until cooldown expires
|
||||
return
|
||||
|
||||
# Get all buffered errors (thread-safe)
|
||||
with self._lock:
|
||||
if not self.error_buffer:
|
||||
return
|
||||
|
||||
errors = list(self.error_buffer)
|
||||
self.error_buffer.clear()
|
||||
|
||||
# Update last sent time
|
||||
self.last_sent = now
|
||||
|
||||
# Format email
|
||||
error_count = len(errors)
|
||||
time_range = (
|
||||
f"{errors[0].timestamp.strftime('%H:%M:%S')} to "
|
||||
f"{errors[-1].timestamp.strftime('%H:%M:%S')}"
|
||||
)
|
||||
|
||||
# Determine alert type for subject
|
||||
alert_type = "Immediate Alert" if immediate else "Scheduled Alert"
|
||||
if immediate:
|
||||
emoji = "🚨"
|
||||
reason = f"(threshold of {self.error_threshold} exceeded)"
|
||||
else:
|
||||
emoji = "⚠️"
|
||||
reason = f"({self.buffer_minutes} minute buffer)"
|
||||
|
||||
subject = f"{emoji} AlpineBits Error {alert_type}: {error_count} errors {reason}"
|
||||
|
||||
# Build plain text body
|
||||
body = f"Error Alert - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
body += "=" * 70 + "\n\n"
|
||||
body += f"Alert Type: {alert_type}\n"
|
||||
body += f"Error Count: {error_count}\n"
|
||||
body += f"Time Range: {time_range}\n"
|
||||
body += f"Reason: {reason}\n"
|
||||
body += "\n" + "=" * 70 + "\n\n"
|
||||
|
||||
# Add individual errors
|
||||
body += "Errors:\n"
|
||||
body += "-" * 70 + "\n\n"
|
||||
for error in errors:
|
||||
body += error.format_plain_text()
|
||||
body += "\n"
|
||||
|
||||
body += "-" * 70 + "\n"
|
||||
body += f"Generated by AlpineBits Email Monitoring at {now.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
|
||||
# Send email
|
||||
try:
|
||||
success = await self.email_service.send_alert(
|
||||
recipients=self.recipients,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
|
||||
if success:
|
||||
_LOGGER.info(
|
||||
"Email alert sent successfully: %d errors to %s",
|
||||
error_count,
|
||||
self.recipients,
|
||||
)
|
||||
else:
|
||||
_LOGGER.error("Failed to send email alert for %d errors", error_count)
|
||||
|
||||
except Exception:
|
||||
_LOGGER.exception("Exception while sending email alert")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the handler and flush any remaining errors.
|
||||
|
||||
This is called when the logging system shuts down.
|
||||
"""
|
||||
# Cancel any pending flush tasks
|
||||
if self._flush_task and not self._flush_task.done():
|
||||
self._flush_task.cancel()
|
||||
|
||||
# Flush any remaining errors immediately
|
||||
if self.error_buffer and self.loop:
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._flush_buffer(immediate=False),
|
||||
self.loop,
|
||||
).result(timeout=5)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error flushing buffer on close")
|
||||
|
||||
super().close()
|
||||
|
||||
|
||||
class DailyReportScheduler:
|
||||
"""Scheduler for sending daily reports at configured times.
|
||||
|
||||
This runs as a background task and sends daily reports containing
|
||||
statistics and error summaries.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
email_service: EmailService,
|
||||
config: dict[str, Any],
|
||||
):
|
||||
"""Initialize the daily report scheduler.
|
||||
|
||||
Args:
|
||||
email_service: Email service for sending reports
|
||||
config: Configuration for daily reports
|
||||
|
||||
"""
|
||||
self.email_service = email_service
|
||||
self.config = config
|
||||
self.recipients = config.get("recipients", [])
|
||||
self.send_time = config.get("send_time", "08:00") # Default 8 AM
|
||||
self.include_stats = config.get("include_stats", True)
|
||||
self.include_errors = config.get("include_errors", True)
|
||||
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stats_collector = None # Will be set by application
|
||||
self._error_log: list[dict[str, Any]] = []
|
||||
|
||||
_LOGGER.info(
|
||||
"DailyReportScheduler initialized: send_time=%s, recipients=%s",
|
||||
self.send_time,
|
||||
self.recipients,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the daily report scheduler."""
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._run())
|
||||
_LOGGER.info("Daily report scheduler started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the daily report scheduler."""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
_LOGGER.info("Daily report scheduler stopped")
|
||||
|
||||
def log_error(self, error: dict[str, Any]) -> None:
|
||||
"""Log an error for inclusion in daily report.
|
||||
|
||||
Args:
|
||||
error: Error information dictionary
|
||||
|
||||
"""
|
||||
self._error_log.append(error)
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""Run the daily report scheduler loop."""
|
||||
while True:
|
||||
try:
|
||||
# Calculate time until next report
|
||||
now = datetime.now()
|
||||
target_hour, target_minute = map(int, self.send_time.split(":"))
|
||||
|
||||
# Calculate next send time
|
||||
next_send = now.replace(
|
||||
hour=target_hour,
|
||||
minute=target_minute,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
|
||||
# If time has passed today, schedule for tomorrow
|
||||
if next_send <= now:
|
||||
next_send += timedelta(days=1)
|
||||
|
||||
# Calculate sleep duration
|
||||
sleep_seconds = (next_send - now).total_seconds()
|
||||
|
||||
_LOGGER.info(
|
||||
"Next daily report scheduled for %s (in %.1f hours)",
|
||||
next_send.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
sleep_seconds / 3600,
|
||||
)
|
||||
|
||||
# Wait until send time
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
|
||||
# Send report
|
||||
await self._send_report()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.info("Daily report scheduler cancelled")
|
||||
break
|
||||
except Exception:
|
||||
_LOGGER.exception("Error in daily report scheduler")
|
||||
# Sleep a bit before retrying
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def _send_report(self) -> None:
|
||||
"""Send the daily report."""
|
||||
stats = {}
|
||||
|
||||
# Collect statistics if enabled
|
||||
if self.include_stats and self._stats_collector:
|
||||
try:
|
||||
stats = await self._stats_collector()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error collecting statistics for daily report")
|
||||
|
||||
# Get errors if enabled
|
||||
errors = self._error_log.copy() if self.include_errors else None
|
||||
|
||||
# Send report
|
||||
try:
|
||||
success = await self.email_service.send_daily_report(
|
||||
recipients=self.recipients,
|
||||
stats=stats,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
if success:
|
||||
_LOGGER.info("Daily report sent successfully to %s", self.recipients)
|
||||
# Clear error log after successful send
|
||||
self._error_log.clear()
|
||||
else:
|
||||
_LOGGER.error("Failed to send daily report")
|
||||
|
||||
except Exception:
|
||||
_LOGGER.exception("Exception while sending daily report")
|
||||
|
||||
def set_stats_collector(self, collector) -> None:
|
||||
"""Set the statistics collector function.
|
||||
|
||||
Args:
|
||||
collector: Async function that returns statistics dictionary
|
||||
|
||||
"""
|
||||
self._stats_collector = collector
|
||||
359
src/alpine_bits_python/email_service.py
Normal file
359
src/alpine_bits_python/email_service.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""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"""
|
||||
<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 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
|
||||
@@ -4,16 +4,32 @@ This module sets up logging based on config and provides a function to get
|
||||
loggers from anywhere in the application.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from alpine_bits_python.email_monitoring import DailyReportScheduler, EmailAlertHandler
|
||||
from alpine_bits_python.email_service import EmailService
|
||||
|
||||
|
||||
def setup_logging(config: dict | None = None):
|
||||
def setup_logging(
|
||||
config: dict | None = None,
|
||||
email_service: "EmailService | None" = None,
|
||||
loop: asyncio.AbstractEventLoop | None = None,
|
||||
) -> tuple["EmailAlertHandler | None", "DailyReportScheduler | None"]:
|
||||
"""Configure logging based on application config.
|
||||
|
||||
Args:
|
||||
config: Application configuration dict with optional 'logger' section
|
||||
email_service: Optional email service for email alerts
|
||||
loop: Optional asyncio event loop for email alerts
|
||||
|
||||
Returns:
|
||||
Tuple of (email_alert_handler, daily_report_scheduler) if email monitoring
|
||||
is enabled, otherwise (None, None)
|
||||
|
||||
Logger config format:
|
||||
logger:
|
||||
@@ -67,6 +83,49 @@ def setup_logging(config: dict | None = None):
|
||||
|
||||
root_logger.info("Logging configured at %s level", level)
|
||||
|
||||
# Setup email monitoring if configured
|
||||
email_handler = None
|
||||
report_scheduler = None
|
||||
|
||||
if email_service:
|
||||
email_config = config.get("email", {})
|
||||
monitoring_config = email_config.get("monitoring", {})
|
||||
|
||||
# Setup error alert handler
|
||||
error_alerts_config = monitoring_config.get("error_alerts", {})
|
||||
if error_alerts_config.get("enabled", False):
|
||||
try:
|
||||
# Import here to avoid circular dependencies
|
||||
from alpine_bits_python.email_monitoring import EmailAlertHandler
|
||||
|
||||
email_handler = EmailAlertHandler(
|
||||
email_service=email_service,
|
||||
config=error_alerts_config,
|
||||
loop=loop,
|
||||
)
|
||||
email_handler.setLevel(logging.ERROR)
|
||||
root_logger.addHandler(email_handler)
|
||||
root_logger.info("Email alert handler enabled for error monitoring")
|
||||
except Exception:
|
||||
root_logger.exception("Failed to setup email alert handler")
|
||||
|
||||
# Setup daily report scheduler
|
||||
daily_report_config = monitoring_config.get("daily_report", {})
|
||||
if daily_report_config.get("enabled", False):
|
||||
try:
|
||||
# Import here to avoid circular dependencies
|
||||
from alpine_bits_python.email_monitoring import DailyReportScheduler
|
||||
|
||||
report_scheduler = DailyReportScheduler(
|
||||
email_service=email_service,
|
||||
config=daily_report_config,
|
||||
)
|
||||
root_logger.info("Daily report scheduler configured")
|
||||
except Exception:
|
||||
root_logger.exception("Failed to setup daily report scheduler")
|
||||
|
||||
return email_handler, report_scheduler
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger instance for the given module name.
|
||||
|
||||
Reference in New Issue
Block a user