feat(email): контактный адрес инстанса, Reply-To в письмах и тестовая отправка

Новая настройка instance_settings.contact_email (включён/адрес, с
валидацией формата) — подставляется в заголовок Reply-To писем
подтверждения регистрации, приглашений и саммари. Админ-эндпоинт
POST /admin/settings/test-email отправляет проверочное письмо синхронно
и возвращает внятный результат (успех либо текст ошибки транспорта),
не раскрывая логин/пароль SMTP.
This commit is contained in:
2026-07-27 00:35:47 +03:00
parent 09a3c6c806
commit 74de51dbd8
8 changed files with 178 additions and 10 deletions

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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")

View File

@@ -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"),
)

View File

@@ -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(

View File

@@ -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(