8 Commits

Author SHA1 Message Date
b35209a775 feat(admin): контактный адрес и тестовое письмо в настройках админки
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Поле включения/адреса в карточке «Контактный адрес» и кнопка «Отправить
тестовое письмо» с выводом результата — рядом, по образцу соседних
настроек AdminSettingsTab.
2026-07-27 00:35:59 +03:00
84b06f78f1 test(email): контактный адрес, Reply-To и тестовая отправка письма
Покрытие валидации/сохранения contact_email, простановки Reply-To в
письмах регистрации/приглашений/саммари при включённом и выключенном
контактном адресе, и эндпоинта тестовой отправки (успех, дефолтный
получатель, сбой транспорта без утечки логина/пароля).
2026-07-27 00:35:53 +03:00
74de51dbd8 feat(email): контактный адрес инстанса, Reply-To в письмах и тестовая отправка
Новая настройка instance_settings.contact_email (включён/адрес, с
валидацией формата) — подставляется в заголовок Reply-To писем
подтверждения регистрации, приглашений и саммари. Админ-эндпоинт
POST /admin/settings/test-email отправляет проверочное письмо синхронно
и возвращает внятный результат (успех либо текст ошибки транспорта),
не раскрывая логин/пароль SMTP.
2026-07-27 00:35:47 +03:00
09a3c6c806 release: версия 0.0.4
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-26 23:53:44 +03:00
86f1facf61 chore: не коммитить локальные конфиги сессий (.claude/) 2026-07-26 23:52:31 +03:00
c897f40363 feat(calendar): информационное окно вхождения — статус, кнопка «Редактировать» для владельца
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
ConferenceOccurrenceDialog расширен под общий информационный диалог (клик по
чипу — и для чужих, и для своих конференций): статус-бейдж (общая логика
occurrenceBadge вынесена в lib/occurrenceStatus.ts, переиспользуется с
ConferenceCalendar), кнопка «Редактировать», открывающая модалку формы —
только когда передан ownedConference (текущий пользователь — организатор).
Плюс Escape и возврат фокуса (useModalDismiss).
2026-07-26 23:35:10 +03:00
66935b04f8 refactor(calendar): чип события — только название и карандашик, заголовок периода без «г.»
В renderEventContent убраны бейдж статуса и время (переехали в
ConferenceOccurrenceDialog) — остались название с обрезкой по многоточию
(min-width:0/overflow:hidden по цепочке флекс-контейнеров, чинит вылезание
текста за границы ячейки) и, у владельца конференции, карандашик
редактирования (myConferenceById/onEditClick, stopPropagation, чтобы не
срабатывал клик по чипу). Клик по чипу — всегда информационное окно, включая
свои конференции.

titleFormat для dayGridWeek/dayGridMonth построен на Intl.DateTimeFormat
вместо ruLocale-форматтера — тот всегда добавляет «г.» после года.
2026-07-26 23:34:49 +03:00
07e5aba9c9 refactor(calendar): форма создания/редактирования конференции — в модальное окно
Раньше ConferenceFormCard всегда висела в сайдбаре двухколоночной сетки
.layout-grid; теперь открывается в модалке (по образцу
ConferenceOccurrenceDialog: modal-overlay/modal-panel, закрытие по клику на
оверлей, Escape и возврат фокуса — новый общий хук useModalDismiss).
.layout-grid стала одноколоночной — календарь занимает всю ширину.
2026-07-26 23:33:52 +03:00
29 changed files with 1055 additions and 140 deletions

View File

@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.3 VIDCONF_VERSION=0.0.4
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

4
.gitignore vendored
View File

@@ -50,3 +50,7 @@ backend/media/
# Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий # Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий
.forcc/ .forcc/
# Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) —
# привязаны к конкретной машине, в репозиторий не идут.
.claude/

View File

@@ -3,6 +3,31 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.4] — 2026-07-26
Календарь конференций: перестроен в раскладку «календарь во всю ширину плюс
модальные окна» — карточки событий разгружены, подробности вынесены во
всплывающее окно.
### Изменения
- Форма создания и редактирования конференции больше не занимает постоянную
колонку справа, а открывается в модальном окне — по кнопке «Новая
конференция», по карандашику на карточке события или из информационного
окна. Добавлен общий хук `useModalDismiss` (закрытие по Escape и возврат
фокуса на элемент, из которого окно открыли).
- Блок календаря занимает всю ширину страницы.
- Карточка события в сетке содержит только название (обрезается многоточием и
больше не вылезает за границы ячейки) и — у организатора — кнопку
редактирования. Время, номер и статус переехали в информационное окно.
- Клик по карточке события всегда открывает информационное окно, в том числе
для своих конференций: редактирование стало отдельным действием.
- Информационное окно вхождения дополнено статус-бейджем и кнопкой
«Редактировать» для организатора.
### Исправления
- Подпись периода в недельном и месячном виде больше не содержит «г.»
(форматирование через `Intl.DateTimeFormat` вместо локали FullCalendar).
## [0.0.3] — 2026-07-26 ## [0.0.3] — 2026-07-26
Мобильная версия: адаптивная вёрстка для узких экранов на ключевых экранах Мобильная версия: адаптивная вёрстка для узких экранов на ключевых экранах
@@ -87,6 +112,7 @@
Первоначальная версия VidConf. Первоначальная версия VidConf.
[0.0.4]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.4
[0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3 [0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3
[0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2 [0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2
[0.0.1]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.1 [0.0.1]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.1

View File

@@ -1 +1 @@
0.0.3 0.0.4

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ from models.team import Team
from models.user import User from models.user import User
from services import ai_levels from services import ai_levels
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.email import EmailSendError
FUTURE = datetime.now(UTC) + timedelta(days=3) 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"}), ("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
("GET", "/api/v1/admin/settings", None), ("GET", "/api/v1/admin/settings", None),
("PUT", "/api/v1/admin/settings", {}), ("PUT", "/api/v1/admin/settings", {}),
("POST", "/api/v1/admin/settings/test-email", {}),
] ]
for method, path, body in requests: for method, path, body in requests:
response = await client.request(method, path, json=body, headers=headers) 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), headers=_auth_headers(admin),
) )
assert response.status_code == 400 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()

View File

@@ -48,6 +48,7 @@ class _CapturingEmailBackend:
def __init__(self) -> None: def __init__(self) -> None:
self.sent: list[tuple[str, str, str]] = [] self.sent: list[tuple[str, str, str]] = []
self.reply_to: list[str | None] = []
async def send( async def send(
self, self,
@@ -57,8 +58,10 @@ class _CapturingEmailBackend:
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:
self.sent.append((to, subject, body)) self.sent.append((to, subject, body))
self.reply_to.append(reply_to)
def _extract_verification_token(email_body: str) -> str: 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 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

View File

@@ -48,6 +48,7 @@ from services.instance_settings import (
BootstrapOverrides, BootstrapOverrides,
InstanceSettingsService, InstanceSettingsService,
InvalidAiLevelError, InvalidAiLevelError,
InvalidContactEmailError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidTimezoneError, InvalidTimezoneError,
SettingsUpdateIn, SettingsUpdateIn,
@@ -69,6 +70,7 @@ _MANAGED_KEYS = (
"display_timezone", "display_timezone",
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email",
) )
@@ -123,6 +125,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"display_timezone", "display_timezone",
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email",
} }
cfg = await service.get() cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu" 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_team_choice is False
assert cfg.registration_email_domain_enabled is False assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None 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( 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" 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( async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:

View File

@@ -19,6 +19,7 @@ from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import text from sqlalchemy import text
from core.db import engine 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.conference_ids import generate_number, generate_slug
from services.email import EmailAttachment, EmailSendError from services.email import EmailAttachment, EmailSendError
from workers.tasks import invitations as invitations_module from workers.tasks import invitations as invitations_module
@@ -65,10 +66,13 @@ class _FakeEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: tuple[EmailAttachment, ...] = (), attachments: tuple[EmailAttachment, ...] = (),
reply_to: str | None = None,
) -> None: ) -> None:
if to in self._fail_for: if to in self._fail_for:
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable) 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: class _Fixture:
@@ -364,6 +368,48 @@ async def test_pinned_conference_sends_to_owner_and_past_participants_with_email
assert delivered == sent_to 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( async def test_explicit_emails_override_default_recipients(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:

View File

@@ -60,6 +60,7 @@ class _FakeEmailBackend:
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None: def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
self.sent: list[str] = [] self.sent: list[str] = []
self.reply_to: list[str | None] = []
self._fail_for = fail_for or set() self._fail_for = fail_for or set()
self._retryable = retryable self._retryable = retryable
@@ -71,10 +72,12 @@ class _FakeEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Any = (), attachments: Any = (),
reply_to: str | None = None,
) -> None: ) -> None:
if to in self._fail_for: if to in self._fail_for:
raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable) raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable)
self.sent.append(to) self.sent.append(to)
self.reply_to.append(reply_to)
class _Fixture: class _Fixture:
@@ -221,12 +224,19 @@ async def fx() -> AsyncGenerator[_Fixture, None]:
await conn.commit() 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( return InstanceConfig(
transcriber=TranscriberConfig(), transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(), summarizer=SummarizerConfig(),
chat=ChatConfig(), chat=ChatConfig(),
summary_recipients=cast("Any", summary_recipients), 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" 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( async def test_notify_session_conference_override_wins_over_instance_default(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:

View File

@@ -7,6 +7,8 @@
""" """
import logging import logging
from email.message import EmailMessage
from typing import cast
import aiosmtplib import aiosmtplib
import pytest 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" 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: def _backend() -> SmtpEmailBackend:
return SmtpEmailBackend( return SmtpEmailBackend(
hostname="smtp.example.com", hostname="smtp.example.com",
@@ -168,3 +197,23 @@ async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.Mo
"use_tls": False, "use_tls": False,
"timeout": 30, "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"

View File

@@ -82,7 +82,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.3} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.4}
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки — # config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
# только backend/), поэтому plugins.yaml монтируется отдельно. # только backend/), поэтому plugins.yaml монтируется отдельно.
volumes: volumes:

View File

@@ -34,6 +34,10 @@ export interface SettingsOut {
registration_email_domain_enabled: boolean registration_email_domain_enabled: boolean
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */ /** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
registration_email_domain: string | 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. */ /** Включение без домена или невалидный домен — backend отвечает 400. */
registration_email_domain_enabled?: boolean registration_email_domain_enabled?: boolean
registration_email_domain?: string | null 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` + сведения о владельце. */ /** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
@@ -238,6 +260,14 @@ export async function updateAdminSettings(payload: SettingsUpdateIn): Promise<Se
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload }) 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>> { export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
const qs = toQueryString({ limit: query.limit, offset: query.offset }) const qs = toQueryString({ limit: query.limit, offset: query.offset })

View File

@@ -1,9 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react' 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 type { SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client' import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
@@ -42,6 +51,7 @@ export function AdminSettingsTab() {
function AdminSettingsForm({ data }: { data: SettingsOut }) { function AdminSettingsForm({ data }: { data: SettingsOut }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toast = useToast() const toast = useToast()
const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_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 [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled) const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '') 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({ const mutation = useMutation({
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload), 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() { function handleSave() {
mutation.mutate({ mutation.mutate({
chat_enabled: chatEnabled, chat_enabled: chatEnabled,
@@ -77,6 +104,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
registration_team_choice: teamChoiceEnabled, registration_team_choice: teamChoiceEnabled,
registration_email_domain_enabled: domainVerificationEnabled, registration_email_domain_enabled: domainVerificationEnabled,
registration_email_domain: emailDomain.trim() || null, 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> </div>
</section> </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"> <div className="settings-actions">
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}> <button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'} {mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}

View File

@@ -5,8 +5,8 @@ import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid' import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import ruLocale from '@fullcalendar/core/locales/ru' import ruLocale from '@fullcalendar/core/locales/ru'
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Lock, Play, Repeat } from 'lucide-react' import { ChevronLeft, ChevronRight, Lock, Pencil } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences' import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard' import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
/* /*
@@ -35,6 +35,16 @@ const HIDDEN_DAYS_NONE: number[] = []
*/ */
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>() const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()
/**
* Актуальный колбэк редактирования из последнего рендера
* `ConferenceCalendar`. `renderEventContent` обязан оставаться одной и той же
* функцией на всё время жизни модуля (см. комментарий выше про
* resetOptions/бесконечный цикл), поэтому не может напрямую замкнуть пропс
* компонента — вместо этого читает его отсюда, а компонент синхронизирует
* переменную эффектом при каждом изменении пропса.
*/
let latestOnEditClick: (conference: ConferenceOut) => void = () => {}
export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth' export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'
interface ConferenceCalendarProps { interface ConferenceCalendarProps {
@@ -42,74 +52,113 @@ interface ConferenceCalendarProps {
onViewChange: (view: CalendarViewMode) => void onViewChange: (view: CalendarViewMode) => void
occurrences: OccurrenceOut[] occurrences: OccurrenceOut[]
isLoading: boolean isLoading: boolean
/** Владельческие конференции текущего пользователя (`/conferences/my`, только свои — `is_owner`) — определяет, показывать ли карандашик на чипе. */
myConferenceById: Map<string, ConferenceOut>
/** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */ /** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
onRangeChange: (fromIso: string, toIso: string) => void onRangeChange: (fromIso: string, toIso: string) => void
onOccurrenceClick: (occurrence: OccurrenceOut) => void onOccurrenceClick: (occurrence: OccurrenceOut) => void
onEditClick: (conference: ConferenceOut) => void
} }
type BadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended' /**
* Не зависит от пропсов/состояния компонента — модульная константа-функция
function occurrenceBadge(occ: OccurrenceOut, start: Date | null, end: Date | null): { kind: BadgeKind; label: string } { * (см. комментарий выше про стабильность identity). В чипе — только название
const now = new Date() * (обрезается многоточием по цепочке min-width:0/overflow:hidden ниже по
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' } * дереву, см. calendar.css) и, у владельца, карандашик редактирования; время/
if (end && now > end) return { kind: 'ended', label: 'Завершена' } * статус/номер — только в информационном окне (`ConferenceOccurrenceDialog`).
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' } */
return { kind: 'scheduled', label: 'Запланирована' }
}
const BADGE_ICON: Record<BadgeKind, typeof Clock> = {
scheduled: Clock,
pinned: Repeat,
live: Play,
ended: CheckCircle2,
}
/** Не зависит от пропсов/состояния компонента — модульная константа-функция (см. комментарий выше про стабильность identity). */
function renderEventContent(arg: EventContentArg) { function renderEventContent(arg: EventContentArg) {
const occ = arg.event.extendedProps.occurrence as OccurrenceOut const occ = arg.event.extendedProps.occurrence as OccurrenceOut
const start = arg.event.start const ownedConference = arg.event.extendedProps.ownedConference as ConferenceOut | undefined
const end = arg.event.end
const badge = occurrenceBadge(occ, start, end)
const Icon = BADGE_ICON[badge.kind]
const timeLabel =
start && end
? `${start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}${end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`
: ''
return ( return (
<div> <div className="chip-row">
<span className={`conf-badge ${badge.kind}`}>
<Icon style={{ width: 11, height: 11 }} aria-hidden="true" />
{badge.label}
</span>
<span className="chip-time">{timeLabel}</span>
<span className="chip-title"> <span className="chip-title">
{occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />} {occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
{arg.event.title} <span className="chip-title-text">{arg.event.title}</span>
</span> </span>
{ownedConference && (
<button
type="button"
className="chip-edit"
aria-label="Редактировать конференцию"
onClick={(e) => {
// Иначе клик долетит и до обработчика чипа (eventClick) — тот
// открыл бы информационное окно вместо формы редактирования.
e.stopPropagation()
latestOnEditClick(ownedConference)
}}
>
<Pencil aria-hidden="true" />
</button>
)}
</div> </div>
) )
} }
/** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */
const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g
/**
* Форма аргумента, который FullCalendar передаёт функции-`titleFormat`
* (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не
* реэкспортируется из публичного `@fullcalendar/core`, поэтому типизируем
* структурно сами, только нужные поля).
*/
interface TitleFormatArg {
start: { marker: Date }
end?: { marker: Date }
}
/**
* Строит titleFormat-функцию FullCalendar на Intl.DateTimeFormat вместо
* ruLocale-зависимого форматтера — тот всегда добавляет «г.» после года.
* FullCalendar передаёт границы диапазона уже с учётом exclusivity (конец
* периода минус 1мс, см. `dateEnv.formatRange` в @fullcalendar/core) — этого
* достаточно, чтобы Intl.formatRange сам корректно схлопнул одинаковые
* месяц/год и красиво отформатировал переход через месяц/год.
*/
function buildTitleFormatter(options: Intl.DateTimeFormatOptions) {
const formatter = new Intl.DateTimeFormat('ru-RU', { ...options, timeZone: 'UTC' })
return (arg: TitleFormatArg): string => {
const raw = arg.end ? formatter.formatRange(arg.start.marker, arg.end.marker) : formatter.format(arg.start.marker)
return raw.replace(YEAR_SUFFIX_RE, '')
}
}
const CALENDAR_VIEWS = {
dayGridWeek: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long', day: 'numeric' }) },
dayGridMonth: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long' }) },
}
/** /**
* Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная * Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная
* (только будни, 5 колонок) / месячная сетка вхождений конференций, свой * (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
* тулбар (вид, навигация по датам) вместо встроенного headerToolbar. * тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
* События рендерятся как «чипы» (`.conf-chip`) со статус-бейджем §4.6 * События рендерятся как «чипы» (`.conf-chip`) — только название (+карандашик
* DESIGN_SYSTEM.md — вместо позиционирования по времени (timeGrid), т.к. * у владельца), статус/время/номер — в информационном окне по клику
* менеджер конференций показывает список вхождений по дням, не занятость * (`ConferenceOccurrenceDialog`) — вместо позиционирования по времени
* переговорной по часам (переговорных комнат в модели нет). * (timeGrid), т.к. менеджер конференций показывает список вхождений по дням,
* не занятость переговорной по часам (переговорных комнат в модели нет).
*/ */
export function ConferenceCalendar({ export function ConferenceCalendar({
view, view,
onViewChange, onViewChange,
occurrences, occurrences,
isLoading, isLoading,
myConferenceById,
onRangeChange, onRangeChange,
onOccurrenceClick, onOccurrenceClick,
onEditClick,
}: ConferenceCalendarProps) { }: ConferenceCalendarProps) {
const calendarRef = useRef<FullCalendar | null>(null) const calendarRef = useRef<FullCalendar | null>(null)
const titleRef = useRef<HTMLSpanElement | null>(null) const titleRef = useRef<HTMLSpanElement | null>(null)
// См. комментарий у `latestOnEditClick` в начале файла — синхронизируем
// переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent.
useEffect(() => {
latestOnEditClick = onEditClick
}, [onEditClick])
// Ховер-карточка конференции — состояние держим внутри // Ховер-карточка конференции — состояние держим внутри
// компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже // компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
// оставались стабильными по identity между рендерами (см. комментарий в // оставались стабильными по identity между рендерами (см. комментарий в
@@ -159,20 +208,24 @@ export function ConferenceCalendar({
calendarRef.current?.getApi().today() calendarRef.current?.getApi().today()
} }
// Мемоизировано по `occurrences` — иначе на каждый рендер создавался бы // Мемоизировано по `occurrences`/`myConferenceById` — иначе на каждый рендер
// новый массив-`events` той же длины/содержимого, и FullCalendar считал бы // создавался бы новый массив-`events` той же длины/содержимого, и
// его «изменившейся опцией» (см. комментарий у модульных констант выше). // FullCalendar считал бы его «изменившейся опцией» (см. комментарий у
// модульных констант выше).
const events: EventInput[] = useMemo( const events: EventInput[] = useMemo(
() => () =>
occurrences.map((occ) => ({ occurrences.map((occ) => {
const owned = myConferenceById.get(occ.conference_id)
return {
id: `${occ.conference_id}:${occ.starts_at}`, id: `${occ.conference_id}:${occ.starts_at}`,
title: occ.title ?? 'Конференция без названия', title: occ.title ?? 'Конференция без названия',
start: occ.starts_at, start: occ.starts_at,
end: occ.ends_at, end: occ.ends_at,
classNames: ['conf-chip'], classNames: ['conf-chip'],
extendedProps: { occurrence: occ }, extendedProps: { occurrence: occ, ownedConference: owned?.is_owner ? owned : undefined },
})), }
[occurrences], }),
[occurrences, myConferenceById],
) )
const handleEventClick = useCallback( const handleEventClick = useCallback(
@@ -228,6 +281,7 @@ export function ConferenceCalendar({
hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE} hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
height="auto" height="auto"
dayMaxEvents={false} dayMaxEvents={false}
views={CALENDAR_VIEWS}
events={events} events={events}
eventContent={renderEventContent} eventContent={renderEventContent}
eventClick={handleEventClick} eventClick={handleEventClick}

View File

@@ -1,10 +1,15 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Calendar, Lock, Play, Repeat, X } from 'lucide-react' import { Calendar, Hash, Lock, Pencil, Play, Repeat, X } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences' import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
import { occurrenceBadge, OCCURRENCE_BADGE_ICON } from '@/lib/occurrenceStatus'
import { useModalDismiss } from '@/hooks/useModalDismiss'
interface ConferenceOccurrenceDialogProps { interface ConferenceOccurrenceDialogProps {
occurrence: OccurrenceOut occurrence: OccurrenceOut
/** Присутствует только когда текущий пользователь — организатор этого вхождения (`is_owner`) — показывает кнопку «Редактировать». */
ownedConference?: ConferenceOut
onClose: () => void onClose: () => void
onEdit?: (conference: ConferenceOut) => void
} }
function formatRange(startsAt: string, endsAt: string): string { function formatRange(startsAt: string, endsAt: string): string {
@@ -18,14 +23,19 @@ function formatRange(startsAt: string, endsAt: string): string {
/** /**
* Быстрый просмотр вхождения конференции по клику на чип в календаре — для * Быстрый просмотр вхождения конференции по клику на чип в календаре — для
* конференций, которые пользователь не организует (не владелец — * всех конференций, включая свои: редактирование теперь отдельное действие
* `is_owner`): свои конференции CalendarPage открывает сразу в форме * (карандашик на чипе или кнопка «Редактировать» здесь, у владельца), сам
* редактирования (`ConferenceFormCard`). Здесь — минимум: когда, статус, вход * клик по чипу всегда открывает это окно. Показывает статус, время, номер и,
* и ссылка на управление; подробности состава участников/организатора — в * для владельца, вход в редактирование; подробности состава
* ховер-карточке при наведении/фокусе на чип (`ConferenceHoverCard`). * участников/организатора — в ховер-карточке при наведении/фокусе на чип
* (`ConferenceHoverCard`).
*/ */
export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOccurrenceDialogProps) { export function ConferenceOccurrenceDialog({ occurrence, ownedConference, onClose, onEdit }: ConferenceOccurrenceDialogProps) {
const navigate = useNavigate() const navigate = useNavigate()
useModalDismiss(onClose)
const badge = occurrenceBadge(occurrence, new Date(occurrence.starts_at), new Date(occurrence.ends_at))
const BadgeIcon = OCCURRENCE_BADGE_ICON[badge.kind]
return ( return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="occurrence-dialog-title" onClick={onClose}> <div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="occurrence-dialog-title" onClick={onClose}>
@@ -40,13 +50,18 @@ export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOc
</button> </button>
</div> </div>
<span className={`conf-badge ${badge.kind}`} style={{ marginBottom: 'var(--space-4)' }}>
<BadgeIcon style={{ width: 11, height: 11 }} aria-hidden="true" />
{badge.label}
</span>
<div className="detail-row"> <div className="detail-row">
<Calendar style={{ width: 18, height: 18 }} aria-hidden="true" /> <Calendar style={{ width: 18, height: 18 }} aria-hidden="true" />
<span className="label">Когда</span> <span className="label">Когда</span>
<span>{formatRange(occurrence.starts_at, occurrence.ends_at)}</span> <span>{formatRange(occurrence.starts_at, occurrence.ends_at)}</span>
</div> </div>
<div className="detail-row"> <div className="detail-row">
<Repeat style={{ width: 18, height: 18 }} aria-hidden="true" /> <Hash style={{ width: 18, height: 18 }} aria-hidden="true" />
<span className="label">Номер</span> <span className="label">Номер</span>
<span>{occurrence.number}</span> <span>{occurrence.number}</span>
</div> </div>
@@ -63,10 +78,18 @@ export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOc
<Play style={{ width: 16, height: 16 }} aria-hidden="true" /> <Play style={{ width: 16, height: 16 }} aria-hidden="true" />
Войти Войти
</button> </button>
{ownedConference && onEdit && (
<button type="button" className="btn btn-secondary" onClick={() => onEdit(ownedConference)}>
<Pencil style={{ width: 16, height: 16 }} aria-hidden="true" />
Редактировать
</button>
)}
</div> </div>
{!ownedConference && (
<p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}> <p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}>
Управление и редактирование в <Link to="/my-conferences">«Моих конференциях»</Link> Управление и редактирование в <Link to="/my-conferences">«Моих конференциях»</Link>
</p> </p>
)}
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react'
/**
* Общее поведение модалок оболочки (`.modal-overlay`/`.modal-panel`):
* закрытие по Escape и возврат фокуса на элемент, который был активен до
* открытия модалки (обычно кнопка, её вызвавшая) — доступность по клавиатуре.
*/
export function useModalDismiss(onClose: () => void) {
const previouslyFocused = useRef<Element | null>(null)
// Всегда актуальный колбэк без пересоздания подписки на keydown при каждом
// рендере (onClose у вызывающих обычно инлайновая функция). Синхронизация —
// отдельным эффектом (не прямым присваиванием в теле рендера, см.
// react-hooks/refs — рефы нельзя трогать во время рендера).
const onCloseRef = useRef(onClose)
useEffect(() => {
onCloseRef.current = onClose
})
useEffect(() => {
previouslyFocused.current = document.activeElement
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onCloseRef.current()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
if (previouslyFocused.current instanceof HTMLElement) previouslyFocused.current.focus()
}
}, [])
}

View File

@@ -0,0 +1,24 @@
import { CheckCircle2, Clock, Play, Repeat } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences'
export type OccurrenceBadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended'
/** Общая логика статус-бейджа вхождения — используется чипом календаря и информационным окном (`ConferenceOccurrenceDialog`). */
export function occurrenceBadge(
occ: OccurrenceOut,
start: Date | null,
end: Date | null,
): { kind: OccurrenceBadgeKind; label: string } {
const now = new Date()
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' }
if (end && now > end) return { kind: 'ended', label: 'Завершена' }
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' }
return { kind: 'scheduled', label: 'Запланирована' }
}
export const OCCURRENCE_BADGE_ICON: Record<OccurrenceBadgeKind, typeof Clock> = {
scheduled: Clock,
pinned: Repeat,
live: Play,
ended: CheckCircle2,
}

View File

@@ -5,6 +5,7 @@ import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar' import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard' import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog' import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
import { useModalDismiss } from '@/hooks/useModalDismiss'
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences' import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
import { pluralizeConferences } from '@/lib/pluralize' import { pluralizeConferences } from '@/lib/pluralize'
import '@/styles/lobby.css' import '@/styles/lobby.css'
@@ -22,17 +23,17 @@ const EMPTY_OCCURRENCES: OccurrenceOut[] = []
* Календарь-менеджер запланированных/закреплённых конференций * Календарь-менеджер запланированных/закреплённых конференций
* (см. design/mockups/calendar.html). * (см. design/mockups/calendar.html).
* *
* Редактирование в сайдбаре доступно только для «своих» вхождений — тех, * Форма создания/редактирования (`ConferenceFormCard`) живёт в модалке, не в
* что присутствуют в `/conferences/my` (закреплённые и предстоящие разовые * сетке страницы — календарь занимает всю ширину. Клик по чипу (в т.ч. по
* владельца; там же полная модель — recurrence/duration, которых нет в * своей конференции) открывает информационное окно (`ConferenceOccurrenceDialog`);
* `OccurrenceOut` календарной выборки). Остальные вхождения по клику * редактирование — отдельное действие: карандашик на чипе (только у
* открывают лёгкий информационный диалог с входом и ссылкой на «Мои * владельца, см. `myConferenceById`) или кнопка «Редактировать» в самом
* конференции» — согласованное отклонение от макета, вызванное отсутствием * информационном окне.
* в контракте эндпоинта получения одной конференции по id.
*/ */
export function CalendarPage() { export function CalendarPage() {
const [view, setView] = useState<CalendarViewMode>('dayGridWeek') const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
const [range, setRange] = useState<VisibleRange | null>(null) const [range, setRange] = useState<VisibleRange | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null) const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null) const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null)
@@ -45,21 +46,18 @@ export function CalendarPage() {
const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences }) const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences]) const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
const handleOccurrenceClick = useCallback( const openCreateForm = useCallback(() => {
(occurrence: OccurrenceOut) => { setEditingConference(null)
// `/conferences/my` может содержать и конференции, куда пользователь setIsFormOpen(true)
// только приглашён участником (не организатор) — редактирование }, [])
// открываем только владельцу, иначе — тот же
// информационный диалог, что и для чужих вхождений. const openEditForm = useCallback((conference: ConferenceOut) => {
const owned = myConferenceById.get(occurrence.conference_id) setEditingConference(conference)
if (owned?.is_owner) { setIsFormOpen(true)
setEditingConference(owned) setOccurrenceDialogTarget(null)
} else { }, [])
setOccurrenceDialogTarget(occurrence)
} const closeForm = useCallback(() => setIsFormOpen(false), [])
},
[myConferenceById],
)
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet` // Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
// повторно всякий раз, когда меняется identity его пропсов (см. подробный // повторно всякий раз, когда меняется identity его пропсов (см. подробный
@@ -71,6 +69,8 @@ export function CalendarPage() {
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso })) setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
}, []) }, [])
const dialogOwnedConference = occurrenceDialogTarget ? myConferenceById.get(occurrenceDialogTarget.conference_id) : undefined
return ( return (
<div className="page-shell"> <div className="page-shell">
<ShellTopbar /> <ShellTopbar />
@@ -83,7 +83,7 @@ export function CalendarPage() {
разовую встречу или закрепите постоянную конференцию с повторением разовую встречу или закрепите постоянную конференцию с повторением
</p> </p>
</div> </div>
<button type="button" className="btn btn-primary" onClick={() => setEditingConference(null)}> <button type="button" className="btn btn-primary" onClick={openCreateForm}>
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" /> <Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
Новая конференция Новая конференция
</button> </button>
@@ -95,23 +95,57 @@ export function CalendarPage() {
onViewChange={setView} onViewChange={setView}
occurrences={occurrences ?? EMPTY_OCCURRENCES} occurrences={occurrences ?? EMPTY_OCCURRENCES}
isLoading={occurrencesLoading} isLoading={occurrencesLoading}
myConferenceById={myConferenceById}
onRangeChange={handleRangeChange} onRangeChange={handleRangeChange}
onOccurrenceClick={handleOccurrenceClick} onOccurrenceClick={setOccurrenceDialogTarget}
/> onEditClick={openEditForm}
<ConferenceFormCard
key={editingConference?.id ?? 'create'}
conference={editingConference ?? undefined}
onSaved={() => setEditingConference(null)}
onDeleted={() => setEditingConference(null)}
onCancelEdit={() => setEditingConference(null)}
/> />
</div> </div>
</main> </main>
{isFormOpen && (
<ConferenceFormModal
conference={editingConference}
onSaved={closeForm}
onDeleted={closeForm}
onClose={closeForm}
/>
)}
{occurrenceDialogTarget && ( {occurrenceDialogTarget && (
<ConferenceOccurrenceDialog occurrence={occurrenceDialogTarget} onClose={() => setOccurrenceDialogTarget(null)} /> <ConferenceOccurrenceDialog
occurrence={occurrenceDialogTarget}
ownedConference={dialogOwnedConference?.is_owner ? dialogOwnedConference : undefined}
onClose={() => setOccurrenceDialogTarget(null)}
onEdit={openEditForm}
/>
)} )}
</div> </div>
) )
} }
interface ConferenceFormModalProps {
conference: ConferenceOut | null
onSaved: () => void
onDeleted: () => void
onClose: () => void
}
/** Модалка создания/редактирования — разметка по образцу `ConferenceOccurrenceDialog` (modal-overlay/modal-panel, закрытие по клику на оверлей, Escape и возврат фокуса). */
function ConferenceFormModal({ conference, onSaved, onDeleted, onClose }: ConferenceFormModalProps) {
useModalDismiss(onClose)
return (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
<div className="modal-form-wrap" onClick={(e) => e.stopPropagation()}>
<ConferenceFormCard
key={conference?.id ?? 'create'}
conference={conference ?? undefined}
onSaved={onSaved}
onDeleted={onDeleted}
onCancelEdit={onClose}
/>
</div>
</div>
)
}

View File

@@ -7,17 +7,20 @@
* (см. комментарий в ConferenceCalendar.tsx). * (см. комментарий в ConferenceCalendar.tsx).
*/ */
/* ---------- Грид: сетка вхождений + карточка создания/редактирования ---------- */ /* ---------- Календарь во всю ширину контента ---------- */
/* Раньше `.layout-grid` была двухколоночной сеткой (календарь + вечно
висящая сбоку `.create-card`) — форма создания/редактирования переехала в
модалку (`.modal-form-wrap`, см. my-conferences.css), сайдбара больше нет.
`min-width:0` оставлен: `.week-card` внутри — единственный элемент грида, и
без этого грид-трек не сжимается уже своего min-content (внутренняя таблица
FullCalendar с 5 колонками) — классический grid blowout, раздувающий трек и
всю страницу шире вьюпорта (см. тот же приём в lobby.css/.action). */
.layout-grid { .layout-grid {
display: grid; display: grid;
grid-template-columns: 1fr 400px; grid-template-columns: 1fr;
gap: var(--space-6); gap: var(--space-6);
align-items: start; align-items: start;
} }
/* min-width:0 — без этого грид-элемент с 1fr-треком не сжимается уже своего
min-content (тут это внутренняя таблица FullCalendar с 5 колонками) —
классический grid blowout: трек и вся страница раздуваются шире вьюпорта,
появляется горизонтальный скролл (см. тот же приём в lobby.css/.action). */
.layout-grid > * { min-width: 0; } .layout-grid > * { min-width: 0; }
.week-card { .week-card {
@@ -67,12 +70,15 @@
* Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа. * Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа.
* FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из * FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из
* `classNames`) — переопределяем его внешний вид под чип целиком, без * `classNames`) — переопределяем его внешний вид под чип целиком, без
* отдельного контейнера. * отдельного контейнера. Содержимое — только название (+карандашик у
* владельца, время/статус/номер — в ConferenceOccurrenceDialog), поэтому чип —
* одна строка (flex row), а не колонка бейдж/время/название, как раньше.
*/ */
.vc-calendar .fc-daygrid-event.conf-chip { .vc-calendar .fc-daygrid-event.conf-chip {
display: flex !important; display: flex !important;
flex-direction: column; align-items: center;
gap: 4px; gap: 8px;
min-width: 0;
background: var(--color-surface) !important; background: var(--color-surface) !important;
border: 1px solid var(--color-border) !important; border: 1px solid var(--color-border) !important;
border-radius: var(--radius-md) !important; border-radius: var(--radius-md) !important;
@@ -85,10 +91,34 @@
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-time, .vc-calendar .fc-daygrid-event.conf-chip .fc-event-time,
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */ .vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */
.chip-time { display: block; font: var(--text-caption); color: var(--color-ink-500); text-transform: none; letter-spacing: normal; } /* Цепочка min-width:0 + overflow:hidden + text-overflow:ellipsis — без неё
.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); } длинное название распирает чип и вылезает за границы ячейки (в узкой
недельной колонке — особенно). `.fc-daygrid-event-harness` — прямой
flex-item `.fc-daygrid-day-events` (флекс-колонка) выше по файлу: по
умолчанию у флекс-айтема min-width:auto (= min-content), из-за чего он не
сжимается уже ширины своего содержимого — тот самый grid/flex-blowout. */
.vc-calendar .fc-daygrid-event-harness { min-width: 0; }
.chip-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); min-width: 0; flex: 1; }
.chip-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.chip-lock { width: 13px; height: 13px; flex-shrink: 0; } .chip-lock { width: 13px; height: 13px; flex-shrink: 0; }
.chip-edit {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 50%;
border: none;
background: transparent;
color: var(--color-ink-500);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.chip-edit svg { width: 13px; height: 13px; }
.chip-edit:hover { background: var(--color-bg-alt); color: var(--color-ink-900); }
.conf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: var(--radius-full); font: var(--text-caption); font-size: 11px; width: fit-content; } .conf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: var(--radius-full); font: var(--text-caption); font-size: 11px; width: fit-content; }
.conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); } .conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); }
.conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); } .conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); }
@@ -242,17 +272,11 @@
совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3 совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3
— см. комментарий там). Макет calendar.html — desktop-only — см. комментарий там). Макет calendar.html — desktop-only
(`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива (`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива
в нём нет — раскладка ниже подобрана по здравому смыслу: календарь сверху, в нём нет.
карточка создания/редактирования — под ним (сохраняем порядок вёрстки). .layout-grid уже одноколоночная на всех ширинах (§2.2) — двухколоночного
960px-брейкпоинта под сайдбар-форму больше нет, форма теперь в модалке
(.modal-form-wrap .create-card в my-conferences.css гасит sticky сама).
*/ */
@media (max-width: 960px) {
/* Двухколоночный грид (сетка + сайдбар 400px) → одна колонка, сайдбар
перестаёт быть sticky (в потоке одной колонки залипание при скролле
страницы выглядит и работает хуже, чем простое следование за контентом). */
.layout-grid { grid-template-columns: 1fr; }
.create-card { position: static; top: auto; }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.week-card, .create-card { padding: var(--space-4); } .week-card, .create-card { padding: var(--space-4); }
.week-toolbar { flex-direction: column; align-items: stretch; } .week-toolbar { flex-direction: column; align-items: stretch; }

View File

@@ -52,13 +52,6 @@
.icon-btn { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; } .icon-btn { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; }
.icon-btn:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); } .icon-btn:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); }
/* ---------- Модалка редактирования (переиспользует ConferenceFormCard) ---------- */
/* .create-card сама по себе — визуальная панель (фон/обводка/радиус/паддинг из
calendar.css); здесь только гасим её sticky-позиционирование календаря и
ограничиваем высоту внутри модалки. */
.modal-form-wrap { width: 100%; max-width: 480px; }
.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; }
/* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900) /* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900)
(тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая (тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая
тёмный тултип поверх тёмной страницы); заменена на буквальный #333333 тёмный тултип поверх тёмной страницы); заменена на буквальный #333333

View File

@@ -417,6 +417,14 @@
.modal-actions--split .modal-actions-group { display: flex; gap: var(--space-3); } .modal-actions--split .modal-actions-group { display: flex; gap: var(--space-3); }
.modal-actions--split .row-actions { display: flex; align-items: center; gap: var(--space-3); } .modal-actions--split .row-actions { display: flex; align-items: center; gap: var(--space-3); }
/* Модалка вокруг ConferenceFormCard (`.create-card`, «Мои конференции» и
календарь) — сама карточка остаётся визуальной панелью
(фон/обводка/радиус/паддинг из calendar.css), здесь только гасим её
sticky-позиционирование из десктопного сайдбара и ограничиваем высоту
внутри модалки, чтобы скроллилась она сама, а не вся страница. */
.modal-form-wrap { width: 100%; max-width: 480px; }
.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; }
/* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются /* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются
(в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на (в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на
свою строку сверху. */ свою строку сверху. */

View File

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

View File

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