"""Email sending utilities for the jobs scraper.""" from __future__ import annotations from email.message import EmailMessage from typing import Iterable, Sequence import smtplib from web.utils import get_email_settings class EmailConfigurationError(RuntimeError): """Raised when email settings are missing or invalid.""" class EmailDeliveryError(RuntimeError): """Raised when an email fails to send.""" def _normalize_addresses(addresses: Sequence[str] | str | None) -> list[str]: if not addresses: return [] if isinstance(addresses, str): items = [addresses] else: items = list(addresses) cleaned: list[str] = [] seen: set[str] = set() for raw in items: if not isinstance(raw, str): continue addr = raw.strip() if not addr: continue lower = addr.lower() if lower in seen: continue seen.add(lower) cleaned.append(addr) return cleaned def _ensure_recipients(*recipient_groups: Iterable[str]) -> list[str]: merged: list[str] = [] seen: set[str] = set() for group in recipient_groups: for addr in group: lower = addr.lower() if lower in seen: continue seen.add(lower) merged.append(addr) if not merged: raise EmailConfigurationError( "At least one recipient address is required") return merged def send_email( *, subject: str, body: str, to: Sequence[str] | str, cc: Sequence[str] | str | None = None, bcc: Sequence[str] | str | None = None, reply_to: Sequence[str] | str | None = None, settings: dict | None = None, ) -> bool: """Send an email using configured SMTP settings. Returns True when a message is sent, False when email is disabled. Raises EmailConfigurationError for invalid config and EmailDeliveryError for SMTP failures. """ config = settings or get_email_settings() if not config.get("enabled"): return False smtp_cfg = config.get("smtp", {}) host = (smtp_cfg.get("host") or "").strip() if not host: raise EmailConfigurationError("SMTP host is not configured") port = int(smtp_cfg.get("port", 587) or 587) timeout = int(smtp_cfg.get("timeout", 30) or 30) use_ssl = bool(smtp_cfg.get("use_ssl", False)) use_tls = bool(smtp_cfg.get("use_tls", True)) from_address = (config.get("from_address") or smtp_cfg.get("username") or "").strip() if not from_address: raise EmailConfigurationError("From address is not configured") to_list = _normalize_addresses(to) cc_list = _normalize_addresses(cc) bcc_list = _normalize_addresses(bcc) reply_to_list = _normalize_addresses(reply_to) all_recipients = _ensure_recipients(to_list, cc_list, bcc_list) message = EmailMessage() message["Subject"] = subject message["From"] = from_address message["To"] = ", ".join(to_list) if cc_list: message["Cc"] = ", ".join(cc_list) if reply_to_list: message["Reply-To"] = ", ".join(reply_to_list) message.set_content(body) username = (smtp_cfg.get("username") or "").strip() password = smtp_cfg.get("password") or "" client_cls = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP try: with client_cls(host=host, port=port, timeout=timeout) as client: client.ehlo() if use_tls and not use_ssl: client.starttls() client.ehlo() if username: client.login(username, password) client.send_message(message, from_addr=from_address, to_addrs=all_recipients) except EmailConfigurationError: raise except Exception as exc: # pragma: no cover - network errors depend on env raise EmailDeliveryError(str(exc)) from exc return True