"""SMTP-backed email sender. Generic primitive used by registration/verification today, reusable for alert digests and other notifications later. Configuration lives in the `smtp` row of `system_settings` and is loaded once per send (small surface, no caching layer yet to keep behavior obvious after settings changes). """ from __future__ import annotations from email.message import EmailMessage from typing import Optional import aiosmtplib from sqlalchemy.ext.asyncio import AsyncSession from app.core.enums import OtpPurpose class EmailError(Exception): code: str = "EMAIL_ERROR" class EmailNotConfiguredError(EmailError): code = "EMAIL_PROVIDER_NOT_CONFIGURED" class EmailSendError(EmailError): code = "EMAIL_SEND_FAILED" async def _load_smtp_config(db: AsyncSession) -> dict: from app.api.v1.settings import get_setting_payload # local import avoids cycle payload = await get_setting_payload(db, "smtp") if not payload.get("host") or not payload.get("from_address"): raise EmailNotConfiguredError("SMTP host/from_address not set") return payload async def send_email( db: AsyncSession, *, to: str, subject: str, text_body: str, html_body: Optional[str] = None, config: Optional[dict] = None, ) -> None: cfg = config or await _load_smtp_config(db) message = EmailMessage() from_name = (cfg.get("from_name") or "").strip() from_address = cfg["from_address"] message["From"] = f"{from_name} <{from_address}>" if from_name else from_address message["To"] = to message["Subject"] = subject message.set_content(text_body) if html_body: message.add_alternative(html_body, subtype="html") use_tls = bool(cfg.get("use_tls", True)) use_starttls = bool(cfg.get("use_starttls", False)) port = int(cfg.get("port") or (465 if use_tls else 587)) try: await aiosmtplib.send( message, hostname=cfg["host"], port=port, username=cfg.get("username") or None, password=cfg.get("password") or None, use_tls=use_tls and not use_starttls, start_tls=use_starttls, timeout=int(cfg.get("timeout_seconds") or 20), ) except aiosmtplib.SMTPException as exc: raise EmailSendError(str(exc)) from exc except OSError as exc: raise EmailSendError(str(exc)) from exc _SUBJECTS: dict[OtpPurpose, str] = { OtpPurpose.REGISTER: "Confirm your Planet account", OtpPurpose.VERIFY_EMAIL: "Verify your Planet email", OtpPurpose.RESET_PASSWORD: "Reset your Planet password", } _HEADLINES: dict[OtpPurpose, str] = { OtpPurpose.REGISTER: "Welcome to Planet — confirm your email to activate your account.", OtpPurpose.VERIFY_EMAIL: "Confirm your new email address to keep your Planet account active.", OtpPurpose.RESET_PASSWORD: "Use this code to set a new password for your Planet account.", } async def send_verification_email( db: AsyncSession, *, to: str, code: str, purpose: OtpPurpose, config: Optional[dict] = None, ) -> None: subject = _SUBJECTS[purpose] headline = _HEADLINES[purpose] text_body = ( f"{headline}\n\n" f"Your verification code: {code}\n" "This code expires in 10 minutes. If you did not request it, ignore this email.\n" ) html_body = ( f"
{headline}
" f"{code}
" "This code expires in 10 minutes. If you did not request it, ignore this email.
" ) await send_email( db, to=to, subject=subject, text_body=text_body, html_body=html_body, config=config, )