from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime from email.message import EmailMessage from email.utils import formataddr import smtplib from app.config import Settings class EmailConfigurationError(ValueError): """Raised when SMTP settings are incomplete or disabled.""" class EmailDeliveryError(RuntimeError): """Raised when sending email fails.""" @dataclass(frozen=True, slots=True) class SMTPConfig: host: str port: int username: str password: str from_name: str from_address: str to_address: str use_starttls: bool def get_smtp_config(settings: Settings, *, require_enabled: bool = True) -> SMTPConfig: if require_enabled and not settings.smtp_enabled: raise EmailConfigurationError("SMTP is disabled") if not settings.smtp_host: raise EmailConfigurationError("SMTP host is required") if settings.smtp_port <= 0: raise EmailConfigurationError("SMTP port must be greater than zero") if not settings.smtp_from_address: raise EmailConfigurationError("SMTP from address is required") if not settings.smtp_to_address: raise EmailConfigurationError("SMTP to address is required") return SMTPConfig( host=settings.smtp_host, port=settings.smtp_port, username=settings.smtp_username, password=settings.smtp_password, from_name=settings.smtp_from_name, from_address=settings.smtp_from_address, to_address=settings.smtp_to_address, use_starttls=settings.smtp_use_starttls, ) def is_smtp_ready(settings: Settings) -> bool: try: get_smtp_config(settings, require_enabled=False) except EmailConfigurationError: return False return True def send_plaintext_email( settings: Settings, *, subject: str, body: str, recipient: str | None = None, require_enabled: bool = True, ) -> None: smtp_config = get_smtp_config(settings, require_enabled=require_enabled) message = EmailMessage() message["Subject"] = subject message["From"] = _build_from_header(smtp_config) message["To"] = recipient or smtp_config.to_address message.set_content(body) try: with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=10) as smtp: smtp.ehlo() if smtp_config.use_starttls: smtp.starttls() smtp.ehlo() if smtp_config.username: smtp.login(smtp_config.username, smtp_config.password) smtp.send_message( message, from_addr=smtp_config.from_address, to_addrs=[recipient or smtp_config.to_address], ) except (OSError, smtplib.SMTPException) as exc: error_message = _sanitize_error_message(str(exc), smtp_config.password) raise EmailDeliveryError(error_message or "SMTP delivery failed") from exc def send_smtp_test_email(settings: Settings) -> None: send_plaintext_email( settings, subject="Home Automation SMTP Test", body="This is a test email from Home Automation SMTP settings.", require_enabled=False, ) def send_public_ip_changed_email( settings: Settings, *, previous_ipv4: str, current_ipv4: str, detected_at: datetime, ) -> None: send_plaintext_email( settings, subject="Public IP changed", body=( "Your public IPv4 address has changed.\n\n" f"Previous IP: {previous_ipv4}\n" f"Current IP: {current_ipv4}\n" f"Detected at: {_format_utc_timestamp(detected_at)}\n\n" "If you use Namecheap API trusted IP restrictions, you may need to " "update the trusted IP manually.\n" ), ) def _sanitize_error_message(message: str, password: str) -> str: sanitized = message if password: sanitized = sanitized.replace(password, "[redacted]") return sanitized def _format_utc_timestamp(value: datetime) -> str: if value.tzinfo is None: normalized = value.replace(tzinfo=UTC) else: normalized = value.astimezone(UTC) return normalized.strftime("%Y-%m-%d %H:%M:%S UTC") def _build_from_header(smtp_config: SMTPConfig) -> str: if smtp_config.from_name: return formataddr((smtp_config.from_name, smtp_config.from_address)) return smtp_config.from_address