feat(email): контактный адрес инстанса, Reply-To в письмах и тестовая отправка
Новая настройка instance_settings.contact_email (включён/адрес, с валидацией формата) — подставляется в заголовок Reply-To писем подтверждения регистрации, приглашений и саммари. Админ-эндпоинт POST /admin/settings/test-email отправляет проверочное письмо синхронно и возвращает внятный результат (успех либо текст ошибки транспорта), не раскрывая логин/пароль SMTP.
This commit is contained in:
@@ -45,6 +45,8 @@ from schemas.admin import (
|
|||||||
TeamListOut,
|
TeamListOut,
|
||||||
TeamOut,
|
TeamOut,
|
||||||
TeamUpdateIn,
|
TeamUpdateIn,
|
||||||
|
TestEmailIn,
|
||||||
|
TestEmailOut,
|
||||||
)
|
)
|
||||||
from schemas.conferences import ConferenceUpdateIn
|
from schemas.conferences import ConferenceUpdateIn
|
||||||
from services.ai_levels import detect_ai_levels
|
from services.ai_levels import detect_ai_levels
|
||||||
@@ -56,9 +58,11 @@ from services.conferences import (
|
|||||||
InvalidConferenceStateError,
|
InvalidConferenceStateError,
|
||||||
NotConferenceOwnerError,
|
NotConferenceOwnerError,
|
||||||
)
|
)
|
||||||
|
from services.email import EmailSendError, create_email_backend
|
||||||
from services.instance_settings import (
|
from services.instance_settings import (
|
||||||
InstanceSettingsService,
|
InstanceSettingsService,
|
||||||
InvalidAiLevelError,
|
InvalidAiLevelError,
|
||||||
|
InvalidContactEmailError,
|
||||||
InvalidEmailDomainError,
|
InvalidEmailDomainError,
|
||||||
InvalidTimezoneError,
|
InvalidTimezoneError,
|
||||||
SettingsUpdateIn,
|
SettingsUpdateIn,
|
||||||
@@ -391,12 +395,59 @@ async def update_settings(
|
|||||||
service = InstanceSettingsService(session)
|
service = InstanceSettingsService(session)
|
||||||
try:
|
try:
|
||||||
cfg = await service.update(data)
|
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
|
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)
|
queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
|
||||||
return _to_settings_out(cfg, transcription_queue_served=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:
|
def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut:
|
||||||
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
|
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
|
||||||
return SettingsOut(
|
return SettingsOut(
|
||||||
@@ -410,6 +461,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
|
|||||||
registration_team_choice=cfg.registration_team_choice,
|
registration_team_choice=cfg.registration_team_choice,
|
||||||
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
|
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
|
||||||
registration_email_domain=cfg.registration_email_domain,
|
registration_email_domain=cfg.registration_email_domain,
|
||||||
|
contact_email_enabled=cfg.contact_email_enabled,
|
||||||
|
contact_email=cfg.contact_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,3 +82,8 @@ class InstanceConfig(BaseModel):
|
|||||||
# `services/instance_settings.py`.
|
# `services/instance_settings.py`.
|
||||||
registration_email_domain_enabled: bool = False
|
registration_email_domain_enabled: bool = False
|
||||||
registration_email_domain: str | None = None
|
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
|
||||||
|
|||||||
@@ -156,3 +156,28 @@ class SettingsOut(BaseModel):
|
|||||||
registration_team_choice: bool
|
registration_team_choice: bool
|
||||||
registration_email_domain_enabled: bool
|
registration_email_domain_enabled: bool
|
||||||
registration_email_domain: str | None = None
|
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
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ class AuthService:
|
|||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
team_id=team_id,
|
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()
|
await self._session.commit()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -216,7 +217,7 @@ class AuthService:
|
|||||||
await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
|
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)
|
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 бит случайности
|
token = secrets.token_urlsafe(32) # 256 бит случайности
|
||||||
expires_at = datetime.now(UTC) + timedelta(
|
expires_at = datetime.now(UTC) + timedelta(
|
||||||
hours=self._settings.email_verification_ttl_hours
|
hours=self._settings.email_verification_ttl_hours
|
||||||
@@ -231,6 +232,7 @@ class AuthService:
|
|||||||
to=user.email,
|
to=user.email,
|
||||||
subject="Подтверждение регистрации VidConf",
|
subject="Подтверждение регистрации VidConf",
|
||||||
body=f"Для подтверждения email перейдите по ссылке: {link}",
|
body=f"Для подтверждения email перейдите по ссылке: {link}",
|
||||||
|
reply_to=reply_to,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,14 @@ class EmailBackend(Protocol):
|
|||||||
body: str,
|
body: str,
|
||||||
html_body: str | None = None,
|
html_body: str | None = None,
|
||||||
attachments: Sequence[EmailAttachment] = (),
|
attachments: Sequence[EmailAttachment] = (),
|
||||||
|
reply_to: str | None = 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,
|
body: str,
|
||||||
html_body: str | None = None,
|
html_body: str | None = None,
|
||||||
attachments: Sequence[EmailAttachment] = (),
|
attachments: Sequence[EmailAttachment] = (),
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Залогировать письмо (вложения — только имена файлов, без содержимого)."""
|
"""Залогировать письмо (вложения — только имена файлов, без содержимого)."""
|
||||||
attachment_names = ", ".join(a.filename for a in attachments) or "нет"
|
attachment_names = ", ".join(a.filename for a in attachments) or "нет"
|
||||||
logger.info(
|
logger.info(
|
||||||
"EMAIL to=%s subject=%s attachments=[%s]\n%s",
|
"EMAIL to=%s subject=%s reply_to=%s attachments=[%s]\n%s",
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
|
reply_to or "нет",
|
||||||
attachment_names,
|
attachment_names,
|
||||||
body,
|
body,
|
||||||
)
|
)
|
||||||
@@ -116,6 +124,7 @@ class SmtpEmailBackend:
|
|||||||
body: str,
|
body: str,
|
||||||
html_body: str | None = None,
|
html_body: str | None = None,
|
||||||
attachments: Sequence[EmailAttachment] = (),
|
attachments: Sequence[EmailAttachment] = (),
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
|
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
|
||||||
message = _build_message(
|
message = _build_message(
|
||||||
@@ -125,6 +134,7 @@ class SmtpEmailBackend:
|
|||||||
body=body,
|
body=body,
|
||||||
html_body=html_body,
|
html_body=html_body,
|
||||||
attachments=attachments,
|
attachments=attachments,
|
||||||
|
reply_to=reply_to,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await aiosmtplib.send(
|
await aiosmtplib.send(
|
||||||
@@ -165,12 +175,15 @@ def _build_message(
|
|||||||
body: str,
|
body: str,
|
||||||
html_body: str | None,
|
html_body: str | None,
|
||||||
attachments: Sequence[EmailAttachment],
|
attachments: Sequence[EmailAttachment],
|
||||||
|
reply_to: str | None = None,
|
||||||
) -> EmailMessage:
|
) -> EmailMessage:
|
||||||
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
|
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
|
||||||
message = EmailMessage()
|
message = EmailMessage()
|
||||||
message["From"] = sender
|
message["From"] = sender
|
||||||
message["To"] = to
|
message["To"] = to
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
|
if reply_to:
|
||||||
|
message["Reply-To"] = reply_to
|
||||||
message.set_content(body)
|
message.set_content(body)
|
||||||
if html_body is not None:
|
if html_body is not None:
|
||||||
message.add_alternative(html_body, subtype="html")
|
message.add_alternative(html_body, subtype="html")
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
||||||
`ai_level`, `summary_recipients`, `display_timezone`,
|
`ai_level`, `summary_recipients`, `display_timezone`,
|
||||||
`registration_team_choice`, `registration_email_domain`) — новая настройка
|
`registration_team_choice`, `registration_email_domain`, `contact_email`) —
|
||||||
не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
|
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
|
||||||
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
|
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
|
||||||
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
|
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
|
||||||
(например, при рестарте backend) не перетирает уже сделанные администратором
|
(например, при рестарте backend) не перетирает уже сделанные администратором
|
||||||
@@ -17,7 +17,8 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
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 import select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -45,6 +46,7 @@ _KEY_SUMMARY_RECIPIENTS = "summary_recipients"
|
|||||||
_KEY_DISPLAY_TIMEZONE = "display_timezone"
|
_KEY_DISPLAY_TIMEZONE = "display_timezone"
|
||||||
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
|
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
|
||||||
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
|
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
|
||||||
|
_KEY_CONTACT_EMAIL = "contact_email"
|
||||||
|
|
||||||
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
||||||
_KEY_CHAT,
|
_KEY_CHAT,
|
||||||
@@ -62,6 +64,7 @@ _DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
|
|||||||
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
|
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
|
||||||
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
|
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
|
||||||
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
|
_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])?)+$"
|
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):
|
class SettingsUpdateIn(BaseModel):
|
||||||
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
|
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
|
||||||
@@ -88,6 +96,8 @@ class SettingsUpdateIn(BaseModel):
|
|||||||
registration_team_choice: bool | None = None
|
registration_team_choice: bool | None = None
|
||||||
registration_email_domain_enabled: bool | None = None
|
registration_email_domain_enabled: bool | None = None
|
||||||
registration_email_domain: str | None = None
|
registration_email_domain: str | None = None
|
||||||
|
contact_email_enabled: bool | None = None
|
||||||
|
contact_email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BootstrapOverrides(BaseModel):
|
class BootstrapOverrides(BaseModel):
|
||||||
@@ -136,6 +146,7 @@ def build_bootstrap_defaults(
|
|||||||
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
|
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
|
||||||
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
|
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
|
||||||
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
|
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
|
||||||
|
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
|
||||||
}
|
}
|
||||||
if overrides is None:
|
if overrides is None:
|
||||||
return defaults
|
return defaults
|
||||||
@@ -172,6 +183,15 @@ class InvalidEmailDomainError(ValueError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidContactEmailError(ValueError):
|
||||||
|
"""Некорректная настройка контактного адреса инстанса.
|
||||||
|
|
||||||
|
Поднимается при попытке включить контактный адрес без email
|
||||||
|
(`enabled=true` и пустой/отсутствующий email) либо при email, не
|
||||||
|
проходящем валидацию формата (`EmailStr`) — см. `_normalize_contact_email`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class InstanceSettingsService:
|
class InstanceSettingsService:
|
||||||
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
|
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
|
||||||
|
|
||||||
@@ -268,6 +288,28 @@ class InstanceSettingsService:
|
|||||||
cfg.registration_email_domain = domain
|
cfg.registration_email_domain = domain
|
||||||
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "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:
|
if patch.transcription_enabled is not None:
|
||||||
cfg.transcriber = cfg.transcriber.model_copy(
|
cfg.transcriber = cfg.transcriber.model_copy(
|
||||||
update={"enabled": patch.transcription_enabled}
|
update={"enabled": patch.transcription_enabled}
|
||||||
@@ -360,6 +402,16 @@ def _normalize_email_domain(domain: str) -> str:
|
|||||||
return normalized
|
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:
|
def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
||||||
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
|
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
|
||||||
|
|
||||||
@@ -387,4 +439,8 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
|||||||
registration_email_domain=rows.get(
|
registration_email_domain=rows.get(
|
||||||
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
|
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
|
||||||
).get("domain"),
|
).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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -162,9 +162,16 @@ async def send_invitations_async(
|
|||||||
body = "\n".join(body_lines)
|
body = "\n".join(body_lines)
|
||||||
|
|
||||||
backend = create_email_backend(settings)
|
backend = create_email_backend(settings)
|
||||||
|
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
|
||||||
for email in recipients:
|
for email in recipients:
|
||||||
try:
|
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:
|
except EmailSendError as exc:
|
||||||
if not exc.retryable:
|
if not exc.retryable:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -156,9 +156,16 @@ async def notify_session_async(
|
|||||||
)
|
)
|
||||||
|
|
||||||
backend = create_email_backend(get_settings())
|
backend = create_email_backend(get_settings())
|
||||||
|
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
|
||||||
for email in pending:
|
for email in pending:
|
||||||
try:
|
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:
|
except EmailSendError as exc:
|
||||||
if not exc.retryable:
|
if not exc.retryable:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
Reference in New Issue
Block a user