from __future__ import annotations import logging import smtplib from email.message import EmailMessage from typing import Any import httpx from app.core.config import settings class EmailDeliveryError(Exception): pass logger = logging.getLogger("uvicorn.error") def _otp_dev_mode_enabled() -> bool: return bool(getattr(settings, "OTP_DEV_MODE", False)) def _normalize_email(value: str | None) -> str: return str(value or "").strip().lower() def _build_subject(*, code: str, purpose: str, track_number: str | None) -> str: template = str(settings.OTP_EMAIL_SUBJECT_TEMPLATE or "").strip() or "Код подтверждения: {code}" try: return template.format(code=code, purpose=purpose, track_number=track_number or "") except Exception: return f"Код подтверждения: {code}" def _build_body(*, code: str, purpose: str, track_number: str | None) -> str: template = str(settings.OTP_EMAIL_TEMPLATE or "").strip() or "Ваш код подтверждения: {code}" try: return template.format(code=code, purpose=purpose, track_number=track_number or "") except Exception: return f"Ваш код подтверждения: {code}" def _mock_send(*, email: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]: line = f"[OTP EMAIL MOCK] purpose={purpose} email={email} track={track_number or '-'} code={code}" logger.warning(line) return { "provider": "mock_email", "status": "accepted", "message": "Email provider response mocked", "sent": False, "mocked": True, "dev_mode": bool(_otp_dev_mode_enabled()), "debug_code": str(code), } def _send_smtp(*, email: str, subject: str, body: str) -> dict[str, Any]: host = str(settings.SMTP_HOST or "").strip() port = int(settings.SMTP_PORT or 0) username = str(settings.SMTP_USER or "").strip() password = str(settings.SMTP_PASSWORD or "").strip() sender = str(settings.SMTP_FROM or "").strip() use_tls = bool(getattr(settings, "SMTP_USE_TLS", True)) use_ssl = bool(getattr(settings, "SMTP_USE_SSL", False)) if not host or not port or not sender: raise EmailDeliveryError("Не заданы SMTP_HOST/SMTP_PORT/SMTP_FROM") if use_tls and use_ssl: raise EmailDeliveryError("Нельзя включать одновременно SMTP_USE_TLS и SMTP_USE_SSL") msg = EmailMessage() msg["From"] = sender msg["To"] = email msg["Subject"] = subject msg.set_content(body) try: if use_ssl: smtp = smtplib.SMTP_SSL(host=host, port=port, timeout=15) else: smtp = smtplib.SMTP(host=host, port=port, timeout=15) with smtp as client: client.ehlo() if use_tls: client.starttls() client.ehlo() if username: client.login(username, password) client.send_message(msg) except Exception as exc: raise EmailDeliveryError(f"Ошибка отправки Email OTP: {exc}") from exc return { "provider": "smtp", "status": "accepted", "message": "Email отправлен", "sent": True, } def send_email_via_smtp(*, email: str, subject: str, body: str) -> dict[str, Any]: normalized_email = _normalize_email(email) if not normalized_email: raise EmailDeliveryError("Некорректный email") return _send_smtp(email=normalized_email, subject=subject, body=body) def _send_via_email_service(*, email: str, subject: str, body: str) -> dict[str, Any]: base_url = str(settings.EMAIL_SERVICE_URL or "").strip().rstrip("/") token = str(settings.INTERNAL_SERVICE_TOKEN or "").strip() if not base_url: raise EmailDeliveryError("Не задан EMAIL_SERVICE_URL") if not token: raise EmailDeliveryError("Не задан INTERNAL_SERVICE_TOKEN") try: with httpx.Client(timeout=15.0) as client: response = client.post( f"{base_url}/internal/send-otp", headers={"X-Internal-Token": token, "Content-Type": "application/json"}, json={"email": email, "subject": subject, "body": body}, ) except Exception as exc: raise EmailDeliveryError(f"Ошибка обращения к email-service: {exc}") from exc payload: dict[str, Any] = {} try: payload = response.json() if response.content else {} except Exception: payload = {} if response.status_code >= 400: detail = str(payload.get("detail") or payload.get("error") or response.text or response.status_code) raise EmailDeliveryError(f"email-service ошибка: {detail}") return { "provider": "email-service", "status": "accepted", "message": "Email отправлен через отдельный сервис", "sent": True, "response": payload, } def send_otp_email_message(*, email: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]: if not bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)): raise EmailDeliveryError("Email-рассылка отключена (EMAIL_SERVICE_ENABLED=false)") normalized_email = _normalize_email(email) if not normalized_email: raise EmailDeliveryError("Некорректный email") if _otp_dev_mode_enabled(): return _mock_send(email=normalized_email, code=code, purpose=purpose, track_number=track_number) provider = str(settings.EMAIL_PROVIDER or "dummy").strip().lower() if provider in {"", "dummy", "mock", "console"}: return _mock_send(email=normalized_email, code=code, purpose=purpose, track_number=track_number) subject = _build_subject(code=code, purpose=purpose, track_number=track_number) body = _build_body(code=code, purpose=purpose, track_number=track_number) if provider in {"service", "email_service"}: return _send_via_email_service(email=normalized_email, subject=subject, body=body) if provider == "smtp": return _send_smtp(email=normalized_email, subject=subject, body=body) raise EmailDeliveryError(f"Неизвестный EMAIL_PROVIDER: {provider}") def email_provider_health() -> dict[str, Any]: if not bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)): return { "provider": "disabled", "status": "ok", "mode": "disabled", "dev_mode": bool(_otp_dev_mode_enabled()), "can_send": False, "checks": {"email_service_enabled": False}, "issues": ["EMAIL_SERVICE_ENABLED=false: Email-рассылка отключена"], } provider = str(settings.EMAIL_PROVIDER or "dummy").strip().lower() if _otp_dev_mode_enabled(): return { "provider": provider or "dummy", "effective_provider": "mock_email", "status": "ok", "mode": "mock", "dev_mode": True, "can_send": True, "checks": {"otp_dev_mode": True}, "issues": ["OTP_DEV_MODE включен: реальная Email-рассылка отключена"], } if provider in {"", "dummy", "mock", "console"}: return { "provider": "dummy", "status": "ok", "mode": "mock", "dev_mode": False, "can_send": True, "checks": {"mock_mode": True}, "issues": [], } if provider in {"service", "email_service"}: base_url = str(settings.EMAIL_SERVICE_URL or "").strip().rstrip("/") token = str(settings.INTERNAL_SERVICE_TOKEN or "").strip() checks = {"email_service_url_configured": bool(base_url), "internal_service_token_configured": bool(token)} issues: list[str] = [] if not checks["email_service_url_configured"]: issues.append("Не задан EMAIL_SERVICE_URL") if not checks["internal_service_token_configured"]: issues.append("Не задан INTERNAL_SERVICE_TOKEN") can_send = all(checks.values()) if can_send: try: with httpx.Client(timeout=5.0) as client: response = client.get(f"{base_url}/health") if response.status_code >= 400: can_send = False issues.append(f"email-service недоступен: HTTP {response.status_code}") except Exception as exc: can_send = False issues.append(f"email-service недоступен: {exc}") return { "provider": "email-service", "status": "ok" if can_send else "degraded", "mode": "service", "dev_mode": False, "can_send": can_send, "checks": checks, "issues": issues, } if provider == "smtp": host = str(settings.SMTP_HOST or "").strip() sender = str(settings.SMTP_FROM or "").strip() checks = {"smtp_host_configured": bool(host), "smtp_from_configured": bool(sender)} issues = [] if not checks["smtp_host_configured"]: issues.append("Не задан SMTP_HOST") if not checks["smtp_from_configured"]: issues.append("Не задан SMTP_FROM") return { "provider": "smtp", "status": "ok" if all(checks.values()) else "degraded", "mode": "real", "dev_mode": False, "can_send": all(checks.values()), "checks": checks, "issues": issues, } return { "provider": provider, "status": "error", "mode": "unknown", "dev_mode": False, "can_send": False, "checks": {"provider_supported": False}, "issues": [f"Неизвестный EMAIL_PROVIDER: {provider}"], }