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

Покрытие валидации/сохранения contact_email, простановки Reply-To в
письмах регистрации/приглашений/саммари при включённом и выключенном
контактном адресе, и эндпоинта тестовой отправки (успех, дефолтный
получатель, сбой транспорта без утечки логина/пароля).
This commit is contained in:
2026-07-27 00:35:53 +03:00
parent 74de51dbd8
commit 84b06f78f1
6 changed files with 394 additions and 2 deletions

View File

@@ -24,6 +24,7 @@ from models.team import Team
from models.user import User
from services import ai_levels
from services.conference_ids import generate_number, generate_slug
from services.email import EmailSendError
FUTURE = datetime.now(UTC) + timedelta(days=3)
@@ -85,6 +86,7 @@ async def test_all_admin_endpoints_forbidden_for_non_admin(
("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
("GET", "/api/v1/admin/settings", None),
("PUT", "/api/v1/admin/settings", {}),
("POST", "/api/v1/admin/settings/test-email", {}),
]
for method, path, body in requests:
response = await client.request(method, path, json=body, headers=headers)
@@ -606,3 +608,153 @@ async def test_put_settings_unavailable_ai_level_returns_400(
headers=_auth_headers(admin),
)
assert response.status_code == 400
async def test_put_settings_contact_email_enable_and_persist(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "Contact@VidConf.RU"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["contact_email_enabled"] is True
assert body["contact_email"] == "contact@vidconf.ru"
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert reloaded.json()["contact_email"] == "contact@vidconf.ru"
async def test_put_settings_contact_email_invalid_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "not an email"},
headers=_auth_headers(admin),
)
assert response.status_code == 400
# --- Тестовое письмо ----------------------------------------------------------------
async def test_send_test_email_defaults_to_admin_email(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Без явного `to` тестовое письмо уходит на email текущего администратора."""
admin = await _make_user(db_session, role="admin")
await db_session.commit()
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["success"] is True
assert sent["to"] == admin.email
async def test_send_test_email_to_explicit_recipient(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email",
json={"to": "other@example.com"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert sent["to"] == "other@example.com"
async def test_send_test_email_uses_contact_email_as_reply_to(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "contact@vidconf.example"},
headers=_auth_headers(admin),
)
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
assert sent["reply_to"] == "contact@vidconf.example"
async def test_send_test_email_reports_transport_failure_without_leaking_secrets(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Сбой транспорта — понятный текст ошибки; хост/порт видны, логин/пароль — нет."""
monkeypatch.setenv("EMAIL_BACKEND", "smtp")
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_PORT", "2525")
monkeypatch.setenv("SMTP_USERNAME", "secret-user")
monkeypatch.setenv("SMTP_PASSWORD", "super-secret-password")
get_settings.cache_clear()
class _FailingBackend:
async def send(self, **kwargs: object) -> None:
raise EmailSendError("временный сбой SMTP: сервер недоступен", retryable=True)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FailingBackend())
try:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["success"] is False
assert "сбой SMTP" in body["message"]
assert body["smtp_host"] == "smtp.example.com"
assert body["smtp_port"] == 2525
assert "secret-user" not in response.text
assert "super-secret-password" not in response.text
finally:
get_settings.cache_clear()

View File

@@ -48,6 +48,7 @@ class _CapturingEmailBackend:
def __init__(self) -> None:
self.sent: list[tuple[str, str, str]] = []
self.reply_to: list[str | None] = []
async def send(
self,
@@ -57,8 +58,10 @@ class _CapturingEmailBackend:
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None:
self.sent.append((to, subject, body))
self.reply_to.append(reply_to)
def _extract_verification_token(email_body: str) -> str:
@@ -432,3 +435,34 @@ async def test_register_any_domain_allowed_when_verification_disabled(
},
)
assert response.status_code == 201, response.text
async def test_register_sets_reply_to_when_contact_email_enabled(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес инстанса включён — письмо подтверждения регистрации несёт `Reply-To`."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@vidconf.example")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={"email": "dave@example.com", "name_user": "Dave", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] == "contact@vidconf.example"
async def test_register_no_reply_to_when_contact_email_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
response = await client.post(
"/api/v1/auth/register",
json={"email": "erin@example.com", "name_user": "Erin", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] is None

View File

@@ -48,6 +48,7 @@ from services.instance_settings import (
BootstrapOverrides,
InstanceSettingsService,
InvalidAiLevelError,
InvalidContactEmailError,
InvalidEmailDomainError,
InvalidTimezoneError,
SettingsUpdateIn,
@@ -69,6 +70,7 @@ _MANAGED_KEYS = (
"display_timezone",
"registration_team_choice",
"registration_email_domain",
"contact_email",
)
@@ -123,6 +125,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"display_timezone",
"registration_team_choice",
"registration_email_domain",
"contact_email",
}
cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu"
@@ -132,6 +135,8 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
assert cfg.registration_team_choice is False
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -384,6 +389,68 @@ async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
assert cfg.registration_email_domain == "acme.io"
async def test_contact_email_enable_without_email_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение контактного адреса без email (ни в патче, ни ранее сохранённого) → 400."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidContactEmailError):
await service.update(SettingsUpdateIn(contact_email_enabled=True))
cfg = await service.get()
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
async def test_contact_email_rejects_invalid_format(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidContactEmailError):
await service.update(SettingsUpdateIn(contact_email="not an email"))
cfg = await service.get()
assert cfg.contact_email is None
async def test_contact_email_normalizes_input(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""` Contact@VidConf.RU ` нормализуется в `contact@vidconf.ru` (strip, lower)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(
SettingsUpdateIn(contact_email_enabled=True, contact_email=" Contact@VidConf.RU ")
)
assert cfg.contact_email_enabled is True
assert cfg.contact_email == "contact@vidconf.ru"
reloaded = await service.get()
assert reloaded.contact_email == "contact@vidconf.ru"
async def test_contact_email_can_be_disabled_keeping_stored_email(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Выключение контактного адреса без передачи email не требует его и не роняет валидацию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@acme.io")
)
cfg = await service.update(SettingsUpdateIn(contact_email_enabled=False))
assert cfg.contact_email_enabled is False
assert cfg.contact_email == "contact@acme.io"
async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer(
db_session: AsyncSession, clean_instance_settings: None
) -> None:

View File

@@ -19,6 +19,7 @@ from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import text
from core.db import engine
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
from services.conference_ids import generate_number, generate_slug
from services.email import EmailAttachment, EmailSendError
from workers.tasks import invitations as invitations_module
@@ -65,10 +66,13 @@ class _FakeEmailBackend:
body: str,
html_body: str | None = None,
attachments: tuple[EmailAttachment, ...] = (),
reply_to: str | None = None,
) -> None:
if to in self._fail_for:
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable)
self.sent.append({"to": to, "subject": subject, "attachments": attachments})
self.sent.append(
{"to": to, "subject": subject, "attachments": attachments, "reply_to": reply_to}
)
class _Fixture:
@@ -364,6 +368,48 @@ async def test_pinned_conference_sends_to_owner_and_past_participants_with_email
assert delivered == sent_to
def _cfg(
*, contact_email_enabled: bool = False, contact_email: str | None = None
) -> InstanceConfig:
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
contact_email_enabled=contact_email_enabled,
contact_email=contact_email,
)
async def test_invitations_set_reply_to_when_contact_email_enabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Включённый контактный адрес инстанса — приглашение несёт `Reply-To`."""
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(
_FakeTask(),
fx.conference_id,
plugins_config=_cfg(contact_email_enabled=True, contact_email="contact@vidconf.example"),
)
assert mock_backend.sent
assert all(call["reply_to"] == "contact@vidconf.example" for call in mock_backend.sent)
async def test_invitations_no_reply_to_when_contact_email_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(_FakeTask(), fx.conference_id, plugins_config=_cfg())
assert mock_backend.sent
assert all(call["reply_to"] is None for call in mock_backend.sent)
async def test_explicit_emails_override_default_recipients(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:

View File

@@ -60,6 +60,7 @@ class _FakeEmailBackend:
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
self.sent: list[str] = []
self.reply_to: list[str | None] = []
self._fail_for = fail_for or set()
self._retryable = retryable
@@ -71,10 +72,12 @@ class _FakeEmailBackend:
body: str,
html_body: str | None = None,
attachments: Any = (),
reply_to: str | None = None,
) -> None:
if to in self._fail_for:
raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable)
self.sent.append(to)
self.reply_to.append(reply_to)
class _Fixture:
@@ -221,12 +224,19 @@ async def fx() -> AsyncGenerator[_Fixture, None]:
await conn.commit()
def _cfg(*, summary_recipients: str = "all") -> InstanceConfig:
def _cfg(
*,
summary_recipients: str = "all",
contact_email_enabled: bool = False,
contact_email: str | None = None,
) -> InstanceConfig:
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
summary_recipients=cast("Any", summary_recipients),
contact_email_enabled=contact_email_enabled,
contact_email=contact_email,
)
@@ -291,6 +301,40 @@ async def test_notify_session_mode_owner_sends_only_to_owner(
assert await _fetch_session_status(fx.session_id) == "notified"
async def test_notify_session_sets_reply_to_when_contact_email_enabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Включённый контактный адрес инстанса — саммари-письмо несёт `Reply-To`."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(),
fx.session_id,
plugins_config=_cfg(
summary_recipients="owner",
contact_email_enabled=True,
contact_email="contact@vidconf.example",
),
)
assert backend.reply_to == ["contact@vidconf.example"]
async def test_notify_session_no_reply_to_when_contact_email_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="owner")
)
assert backend.reply_to == [None]
async def test_notify_session_conference_override_wins_over_instance_default(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:

View File

@@ -7,6 +7,8 @@
"""
import logging
from email.message import EmailMessage
from typing import cast
import aiosmtplib
import pytest
@@ -90,6 +92,33 @@ def test_build_message_includes_html_alternative_and_ics_attachment() -> None:
assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
def test_build_message_sets_reply_to_when_given() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Тема",
body="Тело",
html_body=None,
attachments=(),
reply_to="contact@vidconf.example",
)
assert message["Reply-To"] == "contact@vidconf.example"
def test_build_message_omits_reply_to_when_not_given() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Тема",
body="Тело",
html_body=None,
attachments=(),
)
assert message["Reply-To"] is None
def _backend() -> SmtpEmailBackend:
return SmtpEmailBackend(
hostname="smtp.example.com",
@@ -168,3 +197,23 @@ async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.Mo
"use_tls": False,
"timeout": 30,
}
async def test_smtp_backend_send_passes_reply_to_into_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]:
captured["message"] = message
return {}, "OK"
monkeypatch.setattr(aiosmtplib, "send", _fake_send)
backend = _backend()
await backend.send(
to="user@example.com", subject="Тема", body="Тело", reply_to="contact@vidconf.example"
)
message = cast(EmailMessage, captured["message"])
assert message["Reply-To"] == "contact@vidconf.example"