Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

202
backend/services/email.py Normal file
View File

@@ -0,0 +1,202 @@
"""Абстракция отправки email: контракт `EmailBackend`, dev- и SMTP-реализации.
Выбор бэкенда (`console`|`smtp`) — переменная окружения `EMAIL_BACKEND`
(`core/config.py`), не настройка в БД: секреты SMTP — только в `.env`,
а `SettingsOut` админки их не должен видеть.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from email.message import EmailMessage
from typing import TYPE_CHECKING, Protocol
import aiosmtplib
if TYPE_CHECKING:
from core.config import Settings
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class EmailAttachment:
"""Вложение письма (например, `.ics`-приглашение, `text/calendar; method=REQUEST`)."""
filename: str
content: bytes
mime_type: str
class EmailSendError(Exception):
"""Ошибка отправки письма транспортом.
`retryable=True` — временный сбой транспорта (сервер недоступен/оборвал
соединение/таймаут): вызывающая Celery-задача должна повторить попытку.
`retryable=False` — конкретный получатель отклонён сервером (повторять
без изменения адреса бессмысленно) — вызывающая сторона пропускает его,
не роняя всю рассылку (см. `workers/tasks/notify.py`).
"""
def __init__(self, message: str, *, retryable: bool) -> None:
super().__init__(message)
self.retryable = retryable
class EmailBackend(Protocol):
"""Контракт отправки email; новые бэкенды подставляются без правки ядра."""
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
) -> None:
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива)."""
...
class ConsoleEmailBackend:
"""Бэкенд для разработки: пишет письмо в лог вместо реальной отправки."""
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
) -> None:
"""Залогировать письмо (вложения — только имена файлов, без содержимого)."""
attachment_names = ", ".join(a.filename for a in attachments) or "нет"
logger.info(
"EMAIL to=%s subject=%s attachments=[%s]\n%s",
to,
subject,
attachment_names,
body,
)
class SmtpEmailBackend:
"""Бэкенд реальной отправки email через SMTP (`aiosmtplib.send`)."""
def __init__(
self,
*,
hostname: str,
port: int,
username: str | None,
password: str | None,
start_tls: bool,
use_tls: bool,
timeout: float,
sender: str,
) -> None:
self._hostname = hostname
self._port = port
self._username = username
self._password = password
self._start_tls = start_tls
self._use_tls = use_tls
self._timeout = timeout
self._sender = sender
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
) -> None:
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
message = _build_message(
sender=self._sender,
to=to,
subject=subject,
body=body,
html_body=html_body,
attachments=attachments,
)
try:
await aiosmtplib.send(
message,
hostname=self._hostname,
port=self._port,
username=self._username or None,
password=self._password or None,
start_tls=self._start_tls,
use_tls=self._use_tls,
timeout=self._timeout,
)
except aiosmtplib.SMTPRecipientsRefused as exc:
# Сервер отклонил конкретного получателя — повтор не поможет без
# изменения адреса; вызывающая сторона (notify_session) пропускает
# только его, не роняя рассылку остальным получателям.
logger.warning("SMTP: получатель %s отклонён сервером: %s", to, exc)
raise EmailSendError(f"получатель отклонён сервером: {to}", retryable=False) from exc
except (
aiosmtplib.SMTPConnectError,
aiosmtplib.SMTPServerDisconnected,
aiosmtplib.SMTPTimeoutError,
aiosmtplib.SMTPAuthenticationError,
) as exc:
# Временный сбой транспорта — стоит повторить попытку позже.
# `SMTPTimeoutError` после отправки DATA — доставка неизвестна:
# принят at-least-once, редкий дубль
# предпочтительнее потери письма.
logger.warning("SMTP: временный сбой при отправке на %s: %s", to, exc)
raise EmailSendError(f"временный сбой SMTP: {exc}", retryable=True) from exc
def _build_message(
*,
sender: str,
to: str,
subject: str,
body: str,
html_body: str | None,
attachments: Sequence[EmailAttachment],
) -> EmailMessage:
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
message = EmailMessage()
message["From"] = sender
message["To"] = to
message["Subject"] = subject
message.set_content(body)
if html_body is not None:
message.add_alternative(html_body, subtype="html")
for attachment in attachments:
maintype, _, rest = attachment.mime_type.partition("/")
subtype = rest.split(";", 1)[0].strip() or "octet-stream"
message.add_attachment(
attachment.content,
maintype=maintype or "application",
subtype=subtype,
filename=attachment.filename,
)
return message
def create_email_backend(settings: Settings) -> EmailBackend:
"""Собрать бэкенд отправки email по `settings.email_backend` (`console` по умолчанию)."""
if settings.email_backend == "smtp":
return SmtpEmailBackend(
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=settings.smtp_password,
start_tls=settings.smtp_start_tls,
use_tls=settings.smtp_use_tls,
timeout=settings.smtp_timeout_s,
sender=settings.smtp_from,
)
return ConsoleEmailBackend()