Compare commits
3 Commits
09a3c6c806
...
b35209a775
| Author | SHA1 | Date | |
|---|---|---|---|
| b35209a775 | |||
| 84b06f78f1 | |||
| 74de51dbd8 |
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ from models.team import Team
|
||||
from models.user import User
|
||||
from services import ai_levels
|
||||
from services.conference_ids import generate_number, generate_slug
|
||||
from services.email import EmailSendError
|
||||
|
||||
FUTURE = datetime.now(UTC) + timedelta(days=3)
|
||||
|
||||
@@ -85,6 +86,7 @@ async def test_all_admin_endpoints_forbidden_for_non_admin(
|
||||
("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
|
||||
("GET", "/api/v1/admin/settings", None),
|
||||
("PUT", "/api/v1/admin/settings", {}),
|
||||
("POST", "/api/v1/admin/settings/test-email", {}),
|
||||
]
|
||||
for method, path, body in requests:
|
||||
response = await client.request(method, path, json=body, headers=headers)
|
||||
@@ -606,3 +608,153 @@ async def test_put_settings_unavailable_ai_level_returns_400(
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
async def test_put_settings_contact_email_enable_and_persist(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"contact_email_enabled": True, "contact_email": "Contact@VidConf.RU"},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["contact_email_enabled"] is True
|
||||
assert body["contact_email"] == "contact@vidconf.ru"
|
||||
|
||||
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
|
||||
assert reloaded.json()["contact_email"] == "contact@vidconf.ru"
|
||||
|
||||
|
||||
async def test_put_settings_contact_email_invalid_returns_400(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"contact_email_enabled": True, "contact_email": "not an email"},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# --- Тестовое письмо ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_send_test_email_defaults_to_admin_email(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Без явного `to` тестовое письмо уходит на email текущего администратора."""
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
await db_session.commit()
|
||||
|
||||
sent: dict[str, object] = {}
|
||||
|
||||
class _FakeBackend:
|
||||
async def send(self, **kwargs: object) -> None:
|
||||
sent.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert sent["to"] == admin.email
|
||||
|
||||
|
||||
async def test_send_test_email_to_explicit_recipient(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
await db_session.commit()
|
||||
|
||||
sent: dict[str, object] = {}
|
||||
|
||||
class _FakeBackend:
|
||||
async def send(self, **kwargs: object) -> None:
|
||||
sent.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/settings/test-email",
|
||||
json={"to": "other@example.com"},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert sent["to"] == "other@example.com"
|
||||
|
||||
|
||||
async def test_send_test_email_uses_contact_email_as_reply_to(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
await db_session.commit()
|
||||
await client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"contact_email_enabled": True, "contact_email": "contact@vidconf.example"},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
|
||||
sent: dict[str, object] = {}
|
||||
|
||||
class _FakeBackend:
|
||||
async def send(self, **kwargs: object) -> None:
|
||||
sent.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert sent["reply_to"] == "contact@vidconf.example"
|
||||
|
||||
|
||||
async def test_send_test_email_reports_transport_failure_without_leaking_secrets(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Сбой транспорта — понятный текст ошибки; хост/порт видны, логин/пароль — нет."""
|
||||
monkeypatch.setenv("EMAIL_BACKEND", "smtp")
|
||||
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
||||
monkeypatch.setenv("SMTP_PORT", "2525")
|
||||
monkeypatch.setenv("SMTP_USERNAME", "secret-user")
|
||||
monkeypatch.setenv("SMTP_PASSWORD", "super-secret-password")
|
||||
get_settings.cache_clear()
|
||||
|
||||
class _FailingBackend:
|
||||
async def send(self, **kwargs: object) -> None:
|
||||
raise EmailSendError("временный сбой SMTP: сервер недоступен", retryable=True)
|
||||
|
||||
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FailingBackend())
|
||||
try:
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["success"] is False
|
||||
assert "сбой SMTP" in body["message"]
|
||||
assert body["smtp_host"] == "smtp.example.com"
|
||||
assert body["smtp_port"] == 2525
|
||||
assert "secret-user" not in response.text
|
||||
assert "super-secret-password" not in response.text
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -48,6 +48,7 @@ class _CapturingEmailBackend:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[str, str, str]] = []
|
||||
self.reply_to: list[str | None] = []
|
||||
|
||||
async def send(
|
||||
self,
|
||||
@@ -57,8 +58,10 @@ class _CapturingEmailBackend:
|
||||
body: str,
|
||||
html_body: str | None = None,
|
||||
attachments: Sequence[EmailAttachment] = (),
|
||||
reply_to: str | None = None,
|
||||
) -> None:
|
||||
self.sent.append((to, subject, body))
|
||||
self.reply_to.append(reply_to)
|
||||
|
||||
|
||||
def _extract_verification_token(email_body: str) -> str:
|
||||
@@ -432,3 +435,34 @@ async def test_register_any_domain_allowed_when_verification_disabled(
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
|
||||
async def test_register_sets_reply_to_when_contact_email_enabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
"""Контактный адрес инстанса включён — письмо подтверждения регистрации несёт `Reply-To`."""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@vidconf.example")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "dave@example.com", "name_user": "Dave", "password": "supersecret1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
assert email_backend.reply_to[-1] == "contact@vidconf.example"
|
||||
|
||||
|
||||
async def test_register_no_reply_to_when_contact_email_disabled(
|
||||
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "erin@example.com", "name_user": "Erin", "password": "supersecret1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
assert email_backend.reply_to[-1] is None
|
||||
|
||||
@@ -48,6 +48,7 @@ from services.instance_settings import (
|
||||
BootstrapOverrides,
|
||||
InstanceSettingsService,
|
||||
InvalidAiLevelError,
|
||||
InvalidContactEmailError,
|
||||
InvalidEmailDomainError,
|
||||
InvalidTimezoneError,
|
||||
SettingsUpdateIn,
|
||||
@@ -69,6 +70,7 @@ _MANAGED_KEYS = (
|
||||
"display_timezone",
|
||||
"registration_team_choice",
|
||||
"registration_email_domain",
|
||||
"contact_email",
|
||||
)
|
||||
|
||||
|
||||
@@ -123,6 +125,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
"display_timezone",
|
||||
"registration_team_choice",
|
||||
"registration_email_domain",
|
||||
"contact_email",
|
||||
}
|
||||
cfg = await service.get()
|
||||
assert cfg.transcriber.provider == "faster_whisper_cpu"
|
||||
@@ -132,6 +135,8 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
assert cfg.registration_team_choice is False
|
||||
assert cfg.registration_email_domain_enabled is False
|
||||
assert cfg.registration_email_domain is None
|
||||
assert cfg.contact_email_enabled is False
|
||||
assert cfg.contact_email is None
|
||||
|
||||
|
||||
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
|
||||
@@ -384,6 +389,68 @@ async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
|
||||
assert cfg.registration_email_domain == "acme.io"
|
||||
|
||||
|
||||
async def test_contact_email_enable_without_email_rejected(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""Включение контактного адреса без email (ни в патче, ни ранее сохранённого) → 400."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
with pytest.raises(InvalidContactEmailError):
|
||||
await service.update(SettingsUpdateIn(contact_email_enabled=True))
|
||||
|
||||
cfg = await service.get()
|
||||
assert cfg.contact_email_enabled is False
|
||||
assert cfg.contact_email is None
|
||||
|
||||
|
||||
async def test_contact_email_rejects_invalid_format(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
with pytest.raises(InvalidContactEmailError):
|
||||
await service.update(SettingsUpdateIn(contact_email="not an email"))
|
||||
|
||||
cfg = await service.get()
|
||||
assert cfg.contact_email is None
|
||||
|
||||
|
||||
async def test_contact_email_normalizes_input(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""` Contact@VidConf.RU ` нормализуется в `contact@vidconf.ru` (strip, lower)."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
cfg = await service.update(
|
||||
SettingsUpdateIn(contact_email_enabled=True, contact_email=" Contact@VidConf.RU ")
|
||||
)
|
||||
|
||||
assert cfg.contact_email_enabled is True
|
||||
assert cfg.contact_email == "contact@vidconf.ru"
|
||||
|
||||
reloaded = await service.get()
|
||||
assert reloaded.contact_email == "contact@vidconf.ru"
|
||||
|
||||
|
||||
async def test_contact_email_can_be_disabled_keeping_stored_email(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""Выключение контактного адреса без передачи email не требует его и не роняет валидацию."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
await service.update(
|
||||
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@acme.io")
|
||||
)
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(contact_email_enabled=False))
|
||||
|
||||
assert cfg.contact_email_enabled is False
|
||||
assert cfg.contact_email == "contact@acme.io"
|
||||
|
||||
|
||||
async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
|
||||
@@ -19,6 +19,7 @@ from celery.exceptions import MaxRetriesExceededError
|
||||
from sqlalchemy import text
|
||||
|
||||
from core.db import engine
|
||||
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
|
||||
from services.conference_ids import generate_number, generate_slug
|
||||
from services.email import EmailAttachment, EmailSendError
|
||||
from workers.tasks import invitations as invitations_module
|
||||
@@ -65,10 +66,13 @@ class _FakeEmailBackend:
|
||||
body: str,
|
||||
html_body: str | None = None,
|
||||
attachments: tuple[EmailAttachment, ...] = (),
|
||||
reply_to: str | None = None,
|
||||
) -> None:
|
||||
if to in self._fail_for:
|
||||
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable)
|
||||
self.sent.append({"to": to, "subject": subject, "attachments": attachments})
|
||||
self.sent.append(
|
||||
{"to": to, "subject": subject, "attachments": attachments, "reply_to": reply_to}
|
||||
)
|
||||
|
||||
|
||||
class _Fixture:
|
||||
@@ -364,6 +368,48 @@ async def test_pinned_conference_sends_to_owner_and_past_participants_with_email
|
||||
assert delivered == sent_to
|
||||
|
||||
|
||||
def _cfg(
|
||||
*, contact_email_enabled: bool = False, contact_email: str | None = None
|
||||
) -> InstanceConfig:
|
||||
return InstanceConfig(
|
||||
transcriber=TranscriberConfig(),
|
||||
summarizer=SummarizerConfig(),
|
||||
chat=ChatConfig(),
|
||||
contact_email_enabled=contact_email_enabled,
|
||||
contact_email=contact_email,
|
||||
)
|
||||
|
||||
|
||||
async def test_invitations_set_reply_to_when_contact_email_enabled(
|
||||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Включённый контактный адрес инстанса — приглашение несёт `Reply-To`."""
|
||||
mock_backend = _FakeEmailBackend()
|
||||
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
|
||||
|
||||
await send_invitations_async(
|
||||
_FakeTask(),
|
||||
fx.conference_id,
|
||||
plugins_config=_cfg(contact_email_enabled=True, contact_email="contact@vidconf.example"),
|
||||
)
|
||||
|
||||
assert mock_backend.sent
|
||||
assert all(call["reply_to"] == "contact@vidconf.example" for call in mock_backend.sent)
|
||||
|
||||
|
||||
async def test_invitations_no_reply_to_when_contact_email_disabled(
|
||||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
|
||||
mock_backend = _FakeEmailBackend()
|
||||
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
|
||||
|
||||
await send_invitations_async(_FakeTask(), fx.conference_id, plugins_config=_cfg())
|
||||
|
||||
assert mock_backend.sent
|
||||
assert all(call["reply_to"] is None for call in mock_backend.sent)
|
||||
|
||||
|
||||
async def test_explicit_emails_override_default_recipients(
|
||||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
@@ -60,6 +60,7 @@ class _FakeEmailBackend:
|
||||
|
||||
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
|
||||
self.sent: list[str] = []
|
||||
self.reply_to: list[str | None] = []
|
||||
self._fail_for = fail_for or set()
|
||||
self._retryable = retryable
|
||||
|
||||
@@ -71,10 +72,12 @@ class _FakeEmailBackend:
|
||||
body: str,
|
||||
html_body: str | None = None,
|
||||
attachments: Any = (),
|
||||
reply_to: str | None = None,
|
||||
) -> None:
|
||||
if to in self._fail_for:
|
||||
raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable)
|
||||
self.sent.append(to)
|
||||
self.reply_to.append(reply_to)
|
||||
|
||||
|
||||
class _Fixture:
|
||||
@@ -221,12 +224,19 @@ async def fx() -> AsyncGenerator[_Fixture, None]:
|
||||
await conn.commit()
|
||||
|
||||
|
||||
def _cfg(*, summary_recipients: str = "all") -> InstanceConfig:
|
||||
def _cfg(
|
||||
*,
|
||||
summary_recipients: str = "all",
|
||||
contact_email_enabled: bool = False,
|
||||
contact_email: str | None = None,
|
||||
) -> InstanceConfig:
|
||||
return InstanceConfig(
|
||||
transcriber=TranscriberConfig(),
|
||||
summarizer=SummarizerConfig(),
|
||||
chat=ChatConfig(),
|
||||
summary_recipients=cast("Any", summary_recipients),
|
||||
contact_email_enabled=contact_email_enabled,
|
||||
contact_email=contact_email,
|
||||
)
|
||||
|
||||
|
||||
@@ -291,6 +301,40 @@ async def test_notify_session_mode_owner_sends_only_to_owner(
|
||||
assert await _fetch_session_status(fx.session_id) == "notified"
|
||||
|
||||
|
||||
async def test_notify_session_sets_reply_to_when_contact_email_enabled(
|
||||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Включённый контактный адрес инстанса — саммари-письмо несёт `Reply-To`."""
|
||||
backend = _FakeEmailBackend()
|
||||
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
|
||||
|
||||
await notify_session_async(
|
||||
_FakeTask(),
|
||||
fx.session_id,
|
||||
plugins_config=_cfg(
|
||||
summary_recipients="owner",
|
||||
contact_email_enabled=True,
|
||||
contact_email="contact@vidconf.example",
|
||||
),
|
||||
)
|
||||
|
||||
assert backend.reply_to == ["contact@vidconf.example"]
|
||||
|
||||
|
||||
async def test_notify_session_no_reply_to_when_contact_email_disabled(
|
||||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
|
||||
backend = _FakeEmailBackend()
|
||||
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
|
||||
|
||||
await notify_session_async(
|
||||
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="owner")
|
||||
)
|
||||
|
||||
assert backend.reply_to == [None]
|
||||
|
||||
|
||||
async def test_notify_session_conference_override_wins_over_instance_default(
|
||||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
from typing import cast
|
||||
|
||||
import aiosmtplib
|
||||
import pytest
|
||||
@@ -90,6 +92,33 @@ def test_build_message_includes_html_alternative_and_ics_attachment() -> None:
|
||||
assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
|
||||
def test_build_message_sets_reply_to_when_given() -> None:
|
||||
message = _build_message(
|
||||
sender="VidConf <no-reply@vidconf.example>",
|
||||
to="user@example.com",
|
||||
subject="Тема",
|
||||
body="Тело",
|
||||
html_body=None,
|
||||
attachments=(),
|
||||
reply_to="contact@vidconf.example",
|
||||
)
|
||||
|
||||
assert message["Reply-To"] == "contact@vidconf.example"
|
||||
|
||||
|
||||
def test_build_message_omits_reply_to_when_not_given() -> None:
|
||||
message = _build_message(
|
||||
sender="VidConf <no-reply@vidconf.example>",
|
||||
to="user@example.com",
|
||||
subject="Тема",
|
||||
body="Тело",
|
||||
html_body=None,
|
||||
attachments=(),
|
||||
)
|
||||
|
||||
assert message["Reply-To"] is None
|
||||
|
||||
|
||||
def _backend() -> SmtpEmailBackend:
|
||||
return SmtpEmailBackend(
|
||||
hostname="smtp.example.com",
|
||||
@@ -168,3 +197,23 @@ async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.Mo
|
||||
"use_tls": False,
|
||||
"timeout": 30,
|
||||
}
|
||||
|
||||
|
||||
async def test_smtp_backend_send_passes_reply_to_into_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]:
|
||||
captured["message"] = message
|
||||
return {}, "OK"
|
||||
|
||||
monkeypatch.setattr(aiosmtplib, "send", _fake_send)
|
||||
backend = _backend()
|
||||
|
||||
await backend.send(
|
||||
to="user@example.com", subject="Тема", body="Тело", reply_to="contact@vidconf.example"
|
||||
)
|
||||
|
||||
message = cast(EmailMessage, captured["message"])
|
||||
assert message["Reply-To"] == "contact@vidconf.example"
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface SettingsOut {
|
||||
registration_email_domain_enabled: boolean
|
||||
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
|
||||
registration_email_domain: string | null
|
||||
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
|
||||
contact_email_enabled: boolean
|
||||
/** Контактный адрес — `null`, если не задан/выключен. */
|
||||
contact_email: string | null
|
||||
}
|
||||
|
||||
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
||||
@@ -48,6 +52,24 @@ export interface SettingsUpdateIn {
|
||||
/** Включение без домена или невалидный домен — backend отвечает 400. */
|
||||
registration_email_domain_enabled?: boolean
|
||||
registration_email_domain?: string | null
|
||||
/** Включение без email или невалидный email — backend отвечает 400. */
|
||||
contact_email_enabled?: boolean
|
||||
contact_email?: string | null
|
||||
}
|
||||
|
||||
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
|
||||
export interface TestEmailIn {
|
||||
/** Не задан — backend отправит на email текущего администратора. */
|
||||
to?: string | null
|
||||
}
|
||||
|
||||
/** Результат тестовой отправки — успех или текст ошибки транспорта. */
|
||||
export interface TestEmailOut {
|
||||
success: boolean
|
||||
message: string
|
||||
/** Хост/порт SMTP — только при `EMAIL_BACKEND=smtp`, без логина/пароля. */
|
||||
smtp_host: string | null
|
||||
smtp_port: number | null
|
||||
}
|
||||
|
||||
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
|
||||
@@ -238,6 +260,14 @@ export async function updateAdminSettings(payload: SettingsUpdateIn): Promise<Se
|
||||
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить тестовое письмо синхронно — проверка почтовой конфигурации.
|
||||
* Результат приходит в теле ответа (`success`/`message`), не через HTTP-статус ошибки.
|
||||
*/
|
||||
export async function sendTestEmail(payload: TestEmailIn = {}): Promise<TestEmailOut> {
|
||||
return apiRequest<TestEmailOut>('/admin/settings/test-email', { method: 'POST', body: payload })
|
||||
}
|
||||
|
||||
/** Список команд для админки — с пагинацией. */
|
||||
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
|
||||
const qs = toQueryString({ limit: query.limit, offset: query.offset })
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin'
|
||||
import {
|
||||
getAdminSettings,
|
||||
sendTestEmail,
|
||||
updateAdminSettings,
|
||||
type AiLevel,
|
||||
type SettingsOut,
|
||||
type SettingsUpdateIn,
|
||||
type TestEmailOut,
|
||||
} from '@/api/admin'
|
||||
import type { SummaryRecipientsMode } from '@/api/conferences'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
@@ -42,6 +51,7 @@ export function AdminSettingsTab() {
|
||||
function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
const { user } = useAuth()
|
||||
|
||||
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
|
||||
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
|
||||
@@ -51,6 +61,10 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
|
||||
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
|
||||
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
|
||||
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
|
||||
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
|
||||
const [testEmailTo, setTestEmailTo] = useState('')
|
||||
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
|
||||
@@ -67,6 +81,19 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
},
|
||||
})
|
||||
|
||||
const testEmailMutation = useMutation({
|
||||
mutationFn: () => sendTestEmail(testEmailTo.trim() ? { to: testEmailTo.trim() } : {}),
|
||||
onSuccess: (result) => setTestEmailResult(result),
|
||||
onError: (err: unknown) => {
|
||||
setTestEmailResult({
|
||||
success: false,
|
||||
message: (err instanceof ApiError && errorDetail(err)) || 'Не удалось отправить тестовое письмо',
|
||||
smtp_host: null,
|
||||
smtp_port: null,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function handleSave() {
|
||||
mutation.mutate({
|
||||
chat_enabled: chatEnabled,
|
||||
@@ -77,6 +104,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
registration_team_choice: teamChoiceEnabled,
|
||||
registration_email_domain_enabled: domainVerificationEnabled,
|
||||
registration_email_domain: emailDomain.trim() || null,
|
||||
contact_email_enabled: contactEmailEnabled,
|
||||
contact_email: contactEmail.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -268,6 +297,80 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Контактный адрес</h2>
|
||||
<p className="desc">
|
||||
Адрес для ответов на письма от инстанса (уходят от no-reply — этот адрес подставляется
|
||||
в заголовок «Reply-To»).
|
||||
</p>
|
||||
|
||||
<div className="settings-card-body">
|
||||
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
|
||||
<div className="toggle-copy">
|
||||
<strong>Контактный адрес включён</strong>
|
||||
<span>Подставляется в Reply-To подтверждения регистрации, приглашений и саммари</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactEmailEnabled}
|
||||
onChange={(e) => setContactEmailEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
|
||||
<label htmlFor="settings-contact-email">Email</label>
|
||||
<input
|
||||
id="settings-contact-email"
|
||||
type="email"
|
||||
placeholder="contact@vidconf.ru"
|
||||
value={contactEmail}
|
||||
disabled={!contactEmailEnabled}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Тестовое письмо</h2>
|
||||
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>
|
||||
|
||||
<div className="settings-card-body">
|
||||
<div className="field">
|
||||
<label htmlFor="settings-test-email-to">Получатель</label>
|
||||
<input
|
||||
id="settings-test-email-to"
|
||||
type="email"
|
||||
placeholder={user?.email ?? 'you@example.com'}
|
||||
value={testEmailTo}
|
||||
onChange={(e) => setTestEmailTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={testEmailMutation.isPending}
|
||||
onClick={() => testEmailMutation.mutate()}
|
||||
>
|
||||
{testEmailMutation.isPending ? 'Отправляем…' : 'Отправить тестовое письмо'}
|
||||
</button>
|
||||
|
||||
{testEmailResult && (
|
||||
<p
|
||||
className="field-hint"
|
||||
style={{ color: testEmailResult.success ? 'var(--color-success)' : 'var(--color-danger)' }}
|
||||
>
|
||||
{testEmailResult.message}
|
||||
{testEmailResult.smtp_host && ` (${testEmailResult.smtp_host}:${testEmailResult.smtp_port})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="settings-actions">
|
||||
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
|
||||
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user