diff --git a/backend/api/admin.py b/backend/api/admin.py index 5e3aeb1..67405db 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -45,6 +45,8 @@ from schemas.admin import ( TeamListOut, TeamOut, TeamUpdateIn, + TestEmailIn, + TestEmailOut, ) from schemas.conferences import ConferenceUpdateIn from services.ai_levels import detect_ai_levels @@ -56,9 +58,11 @@ from services.conferences import ( InvalidConferenceStateError, NotConferenceOwnerError, ) +from services.email import EmailSendError, create_email_backend from services.instance_settings import ( InstanceSettingsService, InvalidAiLevelError, + InvalidContactEmailError, InvalidEmailDomainError, InvalidTimezoneError, SettingsUpdateIn, @@ -391,12 +395,59 @@ async def update_settings( service = InstanceSettingsService(session) try: cfg = await service.update(data) - except (InvalidAiLevelError, InvalidTimezoneError, InvalidEmailDomainError) as exc: + except ( + InvalidAiLevelError, + InvalidTimezoneError, + InvalidEmailDomainError, + InvalidContactEmailError, + ) as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc queue_served = await anyio.to_thread.run_sync(transcription_queue_served) return _to_settings_out(cfg, transcription_queue_served=queue_served) +@router.post("/settings/test-email", response_model=TestEmailOut) +async def send_test_email( + data: TestEmailIn, + admin: Annotated[User, Depends(require_admin)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> TestEmailOut: + """Отправить тестовое письмо синхронно — проверка почтовой конфигурации без + регистрации фиктивного пользователя. + + Получатель по умолчанию — email текущего администратора. Отправка идёт + напрямую из эндпоинта (не через Celery), чтобы результат был виден сразу. + Секреты SMTP (логин/пароль) в ответе не участвуют — только хост/порт, + и то лишь при `EMAIL_BACKEND=smtp` (см. `.env.example`). + """ + app_settings = get_app_settings() + recipient = data.to or admin.email + cfg = await InstanceSettingsService(session).get() + reply_to = cfg.contact_email if cfg.contact_email_enabled else None + is_smtp = app_settings.email_backend == "smtp" + smtp_host = app_settings.smtp_host if is_smtp else None + smtp_port = app_settings.smtp_port if is_smtp else None + + backend = create_email_backend(app_settings) + try: + await backend.send( + to=recipient, + subject="Тестовое письмо VidConf", + body="Это тестовое письмо для проверки почтовой конфигурации инстанса VidConf.", + reply_to=reply_to, + ) + except EmailSendError as exc: + return TestEmailOut( + success=False, message=str(exc), smtp_host=smtp_host, smtp_port=smtp_port + ) + return TestEmailOut( + success=True, + message=f"Письмо успешно отправлено на {recipient}", + smtp_host=smtp_host, + smtp_port=smtp_port, + ) + + def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut: """Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI.""" return SettingsOut( @@ -410,6 +461,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) - registration_team_choice=cfg.registration_team_choice, registration_email_domain_enabled=cfg.registration_email_domain_enabled, registration_email_domain=cfg.registration_email_domain, + contact_email_enabled=cfg.contact_email_enabled, + contact_email=cfg.contact_email, ) diff --git a/backend/core/plugins/config.py b/backend/core/plugins/config.py index 8469a02..30a4785 100644 --- a/backend/core/plugins/config.py +++ b/backend/core/plugins/config.py @@ -82,3 +82,8 @@ class InstanceConfig(BaseModel): # `services/instance_settings.py`. registration_email_domain_enabled: bool = False registration_email_domain: str | None = None + # Контактный адрес инстанса — подставляется в `Reply-To` исходящих писем + # (сами письма уходят от `no-reply@`, отвечать на них некуда без этого + # адреса) — см. `services/instance_settings.py`, `services/email.py`. + contact_email_enabled: bool = False + contact_email: str | None = None diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py index 6577dcf..da9395e 100644 --- a/backend/schemas/admin.py +++ b/backend/schemas/admin.py @@ -156,3 +156,28 @@ class SettingsOut(BaseModel): registration_team_choice: bool registration_email_domain_enabled: bool registration_email_domain: str | None = None + contact_email_enabled: bool + contact_email: str | None = None + + +class TestEmailIn(BaseModel): + """Тело запроса тестовой отправки (`POST /admin/settings/test-email`). + + `to` не задан — получатель по умолчанию: email текущего администратора + (см. `api/admin.py::send_test_email`). + """ + + to: EmailStr | None = None + + +class TestEmailOut(BaseModel): + """Результат тестовой отправки — понятный админу успех/ошибка транспорта. + + `smtp_host`/`smtp_port` — только хост и порт (без логина/пароля, они не + покидают `.env`), заполняются лишь при `EMAIL_BACKEND=smtp`. + """ + + success: bool + message: str + smtp_host: str | None = None + smtp_port: int | None = None diff --git a/backend/services/auth.py b/backend/services/auth.py index 82018e7..0f117fb 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -134,7 +134,8 @@ class AuthService: password_hash=hash_password(password), team_id=team_id, ) - await self._issue_verification_email(user) + reply_to = cfg.contact_email if cfg.contact_email_enabled else None + await self._issue_verification_email(user, reply_to=reply_to) await self._session.commit() return user @@ -216,7 +217,7 @@ class AuthService: await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds) return TokenPair(access_token=access_token, refresh_token=refresh_token) - async def _issue_verification_email(self, user: User) -> None: + async def _issue_verification_email(self, user: User, *, reply_to: str | None = None) -> None: token = secrets.token_urlsafe(32) # 256 бит случайности expires_at = datetime.now(UTC) + timedelta( hours=self._settings.email_verification_ttl_hours @@ -231,6 +232,7 @@ class AuthService: to=user.email, subject="Подтверждение регистрации VidConf", body=f"Для подтверждения email перейдите по ссылке: {link}", + reply_to=reply_to, ) diff --git a/backend/services/email.py b/backend/services/email.py index 02a1bc8..3dd7909 100644 --- a/backend/services/email.py +++ b/backend/services/email.py @@ -56,8 +56,14 @@ class EmailBackend(Protocol): body: str, html_body: str | None = None, attachments: Sequence[EmailAttachment] = (), + reply_to: str | None = None, ) -> None: - """Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива).""" + """Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива). + + `reply_to` — необязательный контактный адрес инстанса (см. + `services/instance_settings.py::InstanceConfig.contact_email`), проставляется + заголовком `Reply-To`, если задан. + """ ... @@ -72,13 +78,15 @@ class ConsoleEmailBackend: body: str, html_body: str | None = None, attachments: Sequence[EmailAttachment] = (), + reply_to: str | None = None, ) -> None: """Залогировать письмо (вложения — только имена файлов, без содержимого).""" attachment_names = ", ".join(a.filename for a in attachments) or "нет" logger.info( - "EMAIL to=%s subject=%s attachments=[%s]\n%s", + "EMAIL to=%s subject=%s reply_to=%s attachments=[%s]\n%s", to, subject, + reply_to or "нет", attachment_names, body, ) @@ -116,6 +124,7 @@ class SmtpEmailBackend: body: str, html_body: str | None = None, attachments: Sequence[EmailAttachment] = (), + reply_to: str | None = None, ) -> None: """Отправить письмо; ошибки транспорта транслируются в `EmailSendError`.""" message = _build_message( @@ -125,6 +134,7 @@ class SmtpEmailBackend: body=body, html_body=html_body, attachments=attachments, + reply_to=reply_to, ) try: await aiosmtplib.send( @@ -165,12 +175,15 @@ def _build_message( body: str, html_body: str | None, attachments: Sequence[EmailAttachment], + reply_to: str | None = None, ) -> EmailMessage: """Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения.""" message = EmailMessage() message["From"] = sender message["To"] = to message["Subject"] = subject + if reply_to: + message["Reply-To"] = reply_to message.set_content(body) if html_body is not None: message.add_alternative(html_body, subtype="html") diff --git a/backend/services/instance_settings.py b/backend/services/instance_settings.py index 05fdba3..44c7bfb 100644 --- a/backend/services/instance_settings.py +++ b/backend/services/instance_settings.py @@ -2,8 +2,8 @@ Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, `ai_level`, `summary_recipients`, `display_timezone`, -`registration_team_choice`, `registration_email_domain`) — новая настройка -не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) +`registration_team_choice`, `registration_email_domain`, `contact_email`) — +новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов (например, при рестарте backend) не перетирает уже сделанные администратором @@ -17,7 +17,8 @@ from pathlib import Path from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr, TypeAdapter +from pydantic import ValidationError as PydanticValidationError from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession @@ -45,6 +46,7 @@ _KEY_SUMMARY_RECIPIENTS = "summary_recipients" _KEY_DISPLAY_TIMEZONE = "display_timezone" _KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice" _KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain" +_KEY_CONTACT_EMAIL = "contact_email" BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( _KEY_CHAT, @@ -62,6 +64,7 @@ _DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"} _DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"} _DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False} _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None} +_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None} # Простой паттерн доменного имени: минимум один символ, минимум одна точка, # метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса), @@ -70,6 +73,11 @@ _EMAIL_DOMAIN_PATTERN = re.compile( r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" ) +# Валидация формата контактного email — переиспользует тот же валидатор, +# что и `EmailStr` в pydantic-схемах (`schemas/admin.py` и др.), без +# отдельного регэкспа под адрес целиком. +_CONTACT_EMAIL_ADAPTER: TypeAdapter[str] = TypeAdapter(EmailStr) + class SettingsUpdateIn(BaseModel): """Частичное обновление настроек инстанса — все поля опциональны (PUT-патч). @@ -88,6 +96,8 @@ class SettingsUpdateIn(BaseModel): registration_team_choice: bool | None = None registration_email_domain_enabled: bool | None = None registration_email_domain: str | None = None + contact_email_enabled: bool | None = None + contact_email: str | None = None class BootstrapOverrides(BaseModel): @@ -136,6 +146,7 @@ def build_bootstrap_defaults( _KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE), _KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE), _KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE), + _KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE), } if overrides is None: return defaults @@ -172,6 +183,15 @@ class InvalidEmailDomainError(ValueError): """ +class InvalidContactEmailError(ValueError): + """Некорректная настройка контактного адреса инстанса. + + Поднимается при попытке включить контактный адрес без email + (`enabled=true` и пустой/отсутствующий email) либо при email, не + проходящем валидацию формата (`EmailStr`) — см. `_normalize_contact_email`. + """ + + class InstanceSettingsService: """CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`.""" @@ -268,6 +288,28 @@ class InstanceSettingsService: cfg.registration_email_domain = domain await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain}) + if patch.contact_email_enabled is not None or patch.contact_email is not None: + contact_enabled = ( + patch.contact_email_enabled + if patch.contact_email_enabled is not None + else cfg.contact_email_enabled + ) + raw_contact_email = ( + patch.contact_email if patch.contact_email is not None else cfg.contact_email + ) + contact_email = ( + _normalize_contact_email(raw_contact_email) if raw_contact_email else None + ) + if contact_enabled and contact_email is None: + raise InvalidContactEmailError( + "нельзя включить контактный адрес без указания email" + ) + cfg.contact_email_enabled = contact_enabled + cfg.contact_email = contact_email + await self._set( + _KEY_CONTACT_EMAIL, {"enabled": contact_enabled, "email": contact_email} + ) + if patch.transcription_enabled is not None: cfg.transcriber = cfg.transcriber.model_copy( update={"enabled": patch.transcription_enabled} @@ -360,6 +402,16 @@ def _normalize_email_domain(domain: str) -> str: return normalized +def _normalize_contact_email(email: str) -> str: + """Нормализовать контактный email (strip, lower) и провалидировать формат.""" + normalized = email.strip().lower() + try: + _CONTACT_EMAIL_ADAPTER.validate_python(normalized) + except PydanticValidationError as exc: + raise InvalidContactEmailError(f"некорректный email: {email!r}") from exc + return normalized + + def _build_config(rows: dict[str, Any]) -> InstanceConfig: """Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей. @@ -387,4 +439,8 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig: registration_email_domain=rows.get( _KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE ).get("domain"), + contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get( + "enabled", False + ), + contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"), ) diff --git a/workers/tasks/invitations.py b/workers/tasks/invitations.py index 9989dfa..8cb46be 100644 --- a/workers/tasks/invitations.py +++ b/workers/tasks/invitations.py @@ -162,9 +162,16 @@ async def send_invitations_async( body = "\n".join(body_lines) backend = create_email_backend(settings) + reply_to = cfg.contact_email if cfg.contact_email_enabled else None for email in recipients: try: - await backend.send(to=email, subject=subject, body=body, attachments=[attachment]) + await backend.send( + to=email, + subject=subject, + body=body, + attachments=[attachment], + reply_to=reply_to, + ) except EmailSendError as exc: if not exc.retryable: logger.warning( diff --git a/workers/tasks/notify.py b/workers/tasks/notify.py index 8e9fb5a..b291f9c 100644 --- a/workers/tasks/notify.py +++ b/workers/tasks/notify.py @@ -156,9 +156,16 @@ async def notify_session_async( ) backend = create_email_backend(get_settings()) + reply_to = cfg.contact_email if cfg.contact_email_enabled else None for email in pending: try: - await backend.send(to=email, subject=subject, body=text_body, html_body=html_body) + await backend.send( + to=email, + subject=subject, + body=text_body, + html_body=html_body, + reply_to=reply_to, + ) except EmailSendError as exc: if not exc.retryable: logger.warning(