2026-04-29 12:11:10 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
2026-04-29 13:03:12 +02:00
|
|
|
from datetime import UTC, datetime
|
2026-04-29 12:11:10 +02:00
|
|
|
from email.message import EmailMessage
|
2026-04-29 13:03:12 +02:00
|
|
|
from email.utils import formataddr
|
2026-04-29 12:11:10 +02:00
|
|
|
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
|
2026-04-29 13:03:12 +02:00
|
|
|
from_name: str
|
2026-04-29 12:11:10 +02:00
|
|
|
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,
|
2026-04-29 13:03:12 +02:00
|
|
|
from_name=settings.smtp_from_name,
|
2026-04-29 12:11:10 +02:00
|
|
|
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
|
2026-04-29 13:03:12 +02:00
|
|
|
message["From"] = _build_from_header(smtp_config)
|
2026-04-29 12:11:10 +02:00
|
|
|
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)
|
2026-04-29 13:03:12 +02:00
|
|
|
smtp.send_message(
|
|
|
|
|
message,
|
|
|
|
|
from_addr=smtp_config.from_address,
|
|
|
|
|
to_addrs=[recipient or smtp_config.to_address],
|
|
|
|
|
)
|
2026-04-29 12:11:10 +02:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 13:03:12 +02:00
|
|
|
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"
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 12:11:10 +02:00
|
|
|
def _sanitize_error_message(message: str, password: str) -> str:
|
|
|
|
|
sanitized = message
|
|
|
|
|
if password:
|
|
|
|
|
sanitized = sanitized.replace(password, "[redacted]")
|
2026-04-29 13:03:12 +02:00
|
|
|
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
|