feat(email): контактный адрес инстанса, Reply-To в письмах и тестовая отправка
Новая настройка instance_settings.contact_email (включён/адрес, с валидацией формата) — подставляется в заголовок Reply-To писем подтверждения регистрации, приглашений и саммари. Админ-эндпоинт POST /admin/settings/test-email отправляет проверочное письмо синхронно и возвращает внятный результат (успех либо текст ошибки транспорта), не раскрывая логин/пароль SMTP.
This commit is contained in:
@@ -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"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user